18 minlesson

Building the Delivery Estimation Service

Building the Delivery Estimation Service

The DeliveryEstimationService encapsulates all delivery estimation logic in a single, testable class. It takes parcel data as input and returns a structured delivery window. In this presentation, you will build the service step by step, starting with transit time rules and ending with a fully functional calculation pipeline.

Defining the Response Model

Before writing the service, define what it returns. The delivery estimate response carries three pieces of information: the earliest possible delivery date, the latest possible delivery date, and a confidence level.

csharp
1public class DeliveryEstimateResult
2{
3 public DateOnly EarliestDelivery { get; init; }
4 public DateOnly LatestDelivery { get; init; }
5 public DeliveryConfidenceLevel Confidence { get; init; }
6}
7
8public enum DeliveryConfidenceLevel
9{
10 Low,
11 Medium,
12 High
13}

Using DateOnly instead of DateTime is intentional. Delivery estimates are about calendar dates, not specific times. A parcel estimated for January 15th could arrive at any time that day.

The init setters make the result immutable after creation. Once an estimate is calculated, it should not be modified, only replaced by a new calculation.

Transit Time Configuration

Transit times for each service type are the foundation of the calculation. Define them as a simple lookup structure:

csharp
1public record TransitTimeRange(int MinDays, int MaxDays);

The service stores transit times in a dictionary keyed by ServiceType:

csharp
1private static readonly Dictionary<ServiceType, TransitTimeRange> DomesticTransitTimes = new()
2{
3 [ServiceType.Economy] = new TransitTimeRange(5, 7),
4 [ServiceType.Standard] = new TransitTimeRange(3, 5),
5 [ServiceType.Express] = new TransitTimeRange(1, 2),
6 [ServiceType.Overnight] = new TransitTimeRange(1, 1)
7};
8
9private const int InternationalMinAdditionalDays = 3;
10private const int InternationalMaxAdditionalDays = 5;

Keeping these as constants in the service is appropriate for a course project. In a production system, these values might come from a configuration file or database, but for our purposes, static values keep the code straightforward.

The Service Interface

Define a clean interface that the controller will depend on:

csharp
1public interface IDeliveryEstimationService
2{
3 DeliveryEstimateResult Calculate(Parcel parcel);
4 DeliveryEstimateResult Recalculate(Parcel parcel, DateOnly fromDate);
5}

Two methods serve two distinct use cases:

  • Calculate produces the initial estimate based on the parcel's creation date
  • Recalculate produces a new estimate starting from a specific date, used after delays

Both return the same DeliveryEstimateResult, keeping the consumer code consistent regardless of which method was called.

Determining Domestic vs. International

The first step in any calculation is determining whether the shipment crosses borders:

csharp
1private static bool IsInternational(Parcel parcel)
2{
3 return !string.Equals(
4 parcel.ShipperAddress.CountryCode,
5 parcel.RecipientAddress.CountryCode,
6 StringComparison.OrdinalIgnoreCase);
7}

Using StringComparison.OrdinalIgnoreCase handles cases where country codes might differ in casing (e.g., "US" vs "us"). This is a defensive practice that costs nothing and prevents subtle bugs.

Getting the Transit Time Range

With the domestic/international flag determined, build the total transit time range:

csharp
1private static TransitTimeRange GetTransitTimeRange(
2 ServiceType serviceType, bool isInternational)
3{
4 var baseRange = DomesticTransitTimes[serviceType];
5
6 if (!isInternational)
7 return baseRange;
8
9 return new TransitTimeRange(
10 baseRange.MinDays + InternationalMinAdditionalDays,
11 baseRange.MaxDays + InternationalMaxAdditionalDays);
12}

This method is pure: no side effects, no database calls. Given the same inputs, it always returns the same output. Pure functions are easy to test and reason about.

Business Day Calculation

Adding business days to a date requires skipping weekends. The algorithm is simple: advance one calendar day at a time, counting only weekdays:

csharp
1private static DateOnly AddBusinessDays(DateOnly startDate, int businessDays)
2{
3 var current = startDate;
4 var added = 0;
5
6 while (added < businessDays)
7 {
8 current = current.AddDays(1);
9
10 if (current.DayOfWeek is not DayOfWeek.Saturday
11 and not DayOfWeek.Sunday)
12 {
13 added++;
14 }
15 }
16
17 return current;
18}

The method starts counting from the day after the start date. If you ship on Monday and the transit time is 1 business day, the result is Tuesday, not Monday.

Handling Edge Cases

Consider a parcel shipped on Friday with 1 business day transit:

Friday (start) → Saturday (skip) → Sunday (skip) → Monday (count 1) → Result: Monday

And a parcel shipped on Thursday with 3 business days:

1Thursday (start) → Friday (count 1) → Saturday (skip) → Sunday (skip)
2 → Monday (count 2) → Tuesday (count 3) → Result: Tuesday

The algorithm handles these cases correctly without special-case code. The loop naturally skips weekends.

The Calculate Method

Now assemble the pieces into the main calculation method:

csharp
1public DeliveryEstimateResult Calculate(Parcel parcel)
2{
3 var isInternational = IsInternational(parcel);
4 var transitRange = GetTransitTimeRange(parcel.ServiceType, isInternational);
5 var startDate = DateOnly.FromDateTime(parcel.CreatedAt);
6
7 var earliest = AddBusinessDays(startDate, transitRange.MinDays);
8 var latest = AddBusinessDays(startDate, transitRange.MaxDays);
9 var confidence = DetermineConfidence(parcel.Status);
10
11 return new DeliveryEstimateResult
12 {
13 EarliestDelivery = earliest,
14 LatestDelivery = latest,
15 Confidence = confidence
16 };
17}

The method follows a clear pipeline: determine shipment type, get transit range, calculate dates, determine confidence, return result. Each step is a simple function call with no branching or complex logic at this level.

Determining Confidence

Confidence maps directly from parcel status:

csharp
1private static DeliveryConfidenceLevel DetermineConfidence(ParcelStatus status)
2{
3 return status switch
4 {
5 ParcelStatus.OutForDelivery => DeliveryConfidenceLevel.High,
6 ParcelStatus.Delivered => DeliveryConfidenceLevel.High,
7 ParcelStatus.InTransit => DeliveryConfidenceLevel.Medium,
8 _ => DeliveryConfidenceLevel.Low
9 };
10}

The switch expression handles every case concisely. OutForDelivery and Delivered get High confidence because the parcel's location is well-known. InTransit gets Medium because the parcel is moving but its exact position and timing are uncertain. Everything else (LabelCreated, PickedUp, Exception, Returned) gets Low.

Using a switch expression with a discard (_) default is idiomatic C#. It handles any new status values that might be added later without throwing exceptions.

The Recalculate Method

Recalculation starts from a different date and may add delay buffers:

csharp
1public DeliveryEstimateResult Recalculate(Parcel parcel, DateOnly fromDate)
2{
3 var isInternational = IsInternational(parcel);
4 var transitRange = GetTransitTimeRange(parcel.ServiceType, isInternational);
5
6 // Use remaining transit time from the new starting point
7 var elapsed = CountBusinessDays(
8 DateOnly.FromDateTime(parcel.CreatedAt), fromDate);
9 var remainingMin = Math.Max(1, transitRange.MinDays - elapsed);
10 var remainingMax = Math.Max(1, transitRange.MaxDays - elapsed);
11
12 var earliest = AddBusinessDays(fromDate, remainingMin);
13 var latest = AddBusinessDays(fromDate, remainingMax);
14 var confidence = DetermineConfidence(parcel.Status);
15
16 return new DeliveryEstimateResult
17 {
18 EarliestDelivery = earliest,
19 LatestDelivery = latest,
20 Confidence = confidence
21 };
22}

The key difference from Calculate is the elapsed time calculation. The method counts how many business days have already passed since the parcel was created, subtracts that from the total transit time, and applies the remainder starting from the new date.

Math.Max(1, ...) ensures the remaining time is always at least 1 business day. Even if the parcel is overdue, the recalculated estimate must be in the future.

Counting Elapsed Business Days

The recalculation method needs a helper to count business days between two dates:

csharp
1private static int CountBusinessDays(DateOnly from, DateOnly to)
2{
3 var count = 0;
4 var current = from;
5
6 while (current < to)
7 {
8 current = current.AddDays(1);
9
10 if (current.DayOfWeek is not DayOfWeek.Saturday
11 and not DayOfWeek.Sunday)
12 {
13 count++;
14 }
15 }
16
17 return count;
18}

This mirrors the AddBusinessDays method but counts forward instead of adding. It starts from the day after from and counts each weekday until it reaches to.

Registering the Service

Register the service in the DI container in Program.cs:

csharp
1builder.Services.AddScoped<IDeliveryEstimationService, DeliveryEstimationService>();

Using AddScoped is appropriate because the service has no state that persists across requests. Each request gets its own instance. AddSingleton would also work since the service has no mutable state, but AddScoped is the conventional choice for services in web applications.

The Complete Service Class

Here is the full service implementation assembled into a single class:

csharp
1public class DeliveryEstimationService : IDeliveryEstimationService
2{
3 private static readonly Dictionary<ServiceType, TransitTimeRange> DomesticTransitTimes = new()
4 {
5 [ServiceType.Economy] = new TransitTimeRange(5, 7),
6 [ServiceType.Standard] = new TransitTimeRange(3, 5),
7 [ServiceType.Express] = new TransitTimeRange(1, 2),
8 [ServiceType.Overnight] = new TransitTimeRange(1, 1)
9 };
10
11 private const int InternationalMinAdditionalDays = 3;
12 private const int InternationalMaxAdditionalDays = 5;
13
14 public DeliveryEstimateResult Calculate(Parcel parcel)
15 {
16 var isInternational = IsInternational(parcel);
17 var transitRange = GetTransitTimeRange(parcel.ServiceType, isInternational);
18 var startDate = DateOnly.FromDateTime(parcel.CreatedAt);
19
20 return new DeliveryEstimateResult
21 {
22 EarliestDelivery = AddBusinessDays(startDate, transitRange.MinDays),
23 LatestDelivery = AddBusinessDays(startDate, transitRange.MaxDays),
24 Confidence = DetermineConfidence(parcel.Status)
25 };
26 }
27
28 public DeliveryEstimateResult Recalculate(Parcel parcel, DateOnly fromDate)
29 {
30 var isInternational = IsInternational(parcel);
31 var transitRange = GetTransitTimeRange(parcel.ServiceType, isInternational);
32
33 var elapsed = CountBusinessDays(
34 DateOnly.FromDateTime(parcel.CreatedAt), fromDate);
35 var remainingMin = Math.Max(1, transitRange.MinDays - elapsed);
36 var remainingMax = Math.Max(1, transitRange.MaxDays - elapsed);
37
38 return new DeliveryEstimateResult
39 {
40 EarliestDelivery = AddBusinessDays(fromDate, remainingMin),
41 LatestDelivery = AddBusinessDays(fromDate, remainingMax),
42 Confidence = DetermineConfidence(parcel.Status)
43 };
44 }
45
46 private static bool IsInternational(Parcel parcel) =>
47 !string.Equals(
48 parcel.ShipperAddress.CountryCode,
49 parcel.RecipientAddress.CountryCode,
50 StringComparison.OrdinalIgnoreCase);
51
52 private static TransitTimeRange GetTransitTimeRange(
53 ServiceType serviceType, bool isInternational)
54 {
55 var baseRange = DomesticTransitTimes[serviceType];
56 return isInternational
57 ? new TransitTimeRange(
58 baseRange.MinDays + InternationalMinAdditionalDays,
59 baseRange.MaxDays + InternationalMaxAdditionalDays)
60 : baseRange;
61 }
62
63 private static DeliveryConfidenceLevel DetermineConfidence(ParcelStatus status) =>
64 status switch
65 {
66 ParcelStatus.OutForDelivery => DeliveryConfidenceLevel.High,
67 ParcelStatus.Delivered => DeliveryConfidenceLevel.High,
68 ParcelStatus.InTransit => DeliveryConfidenceLevel.Medium,
69 _ => DeliveryConfidenceLevel.Low
70 };
71
72 private static DateOnly AddBusinessDays(DateOnly startDate, int businessDays)
73 {
74 var current = startDate;
75 var added = 0;
76 while (added < businessDays)
77 {
78 current = current.AddDays(1);
79 if (current.DayOfWeek is not DayOfWeek.Saturday
80 and not DayOfWeek.Sunday)
81 added++;
82 }
83 return current;
84 }
85
86 private static int CountBusinessDays(DateOnly from, DateOnly to)
87 {
88 var count = 0;
89 var current = from;
90 while (current < to)
91 {
92 current = current.AddDays(1);
93 if (current.DayOfWeek is not DayOfWeek.Saturday
94 and not DayOfWeek.Sunday)
95 count++;
96 }
97 return count;
98 }
99}

Notice that every helper method is private static. They do not access instance state and do not need to. This makes the service lightweight and signals that these are pure utility calculations.

Testing the Service

Because the service is a pure calculation layer with no database dependencies, testing is straightforward:

csharp
1[Fact]
2public void Calculate_DomesticStandard_ReturnsCorrectWindow()
3{
4 var service = new DeliveryEstimationService();
5 var parcel = CreateTestParcel(
6 serviceType: ServiceType.Standard,
7 shipperCountry: "US",
8 recipientCountry: "US",
9 createdAt: new DateTime(2025, 1, 6)); // Monday
10
11 var result = service.Calculate(parcel);
12
13 Assert.Equal(new DateOnly(2025, 1, 9), result.EarliestDelivery); // Thursday
14 Assert.Equal(new DateOnly(2025, 1, 13), result.LatestDelivery); // Monday
15}
16
17[Fact]
18public void Calculate_InternationalExpress_AddsInternationalDays()
19{
20 var service = new DeliveryEstimationService();
21 var parcel = CreateTestParcel(
22 serviceType: ServiceType.Express,
23 shipperCountry: "US",
24 recipientCountry: "GB",
25 createdAt: new DateTime(2025, 1, 6)); // Monday
26
27 var result = service.Calculate(parcel);
28
29 // Express (1-2) + International (3-5) = 4-7 business days
30 Assert.Equal(new DateOnly(2025, 1, 10), result.EarliestDelivery); // Friday
31 Assert.Equal(new DateOnly(2025, 1, 15), result.LatestDelivery); // Wednesday
32}

No mocking frameworks needed. No in-memory databases. Just create a parcel, call the method, and assert the result. This is the payoff of extracting business logic into a service with no infrastructure dependencies.

Summary

In this presentation, you built:

  • A DeliveryEstimateResult model with earliest/latest dates and confidence
  • Transit time lookup tables for each service type
  • Domestic vs. international detection using country codes
  • Business day calculation that skips weekends
  • A Calculate method for initial estimates and a Recalculate method for updates
  • An IDeliveryEstimationService interface for dependency injection
  • Unit tests that verify the calculation logic without any infrastructure
Building the Delivery Estimation Service - Anko Academy