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.
csharp1public class DeliveryEstimateResult2{3 public DateOnly EarliestDelivery { get; init; }4 public DateOnly LatestDelivery { get; init; }5 public DeliveryConfidenceLevel Confidence { get; init; }6}78public enum DeliveryConfidenceLevel9{10 Low,11 Medium,12 High13}
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:
csharp1public record TransitTimeRange(int MinDays, int MaxDays);
The service stores transit times in a dictionary keyed by ServiceType:
csharp1private 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};89private 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:
csharp1public interface IDeliveryEstimationService2{3 DeliveryEstimateResult Calculate(Parcel parcel);4 DeliveryEstimateResult Recalculate(Parcel parcel, DateOnly fromDate);5}
Two methods serve two distinct use cases:
Calculateproduces the initial estimate based on the parcel's creation dateRecalculateproduces 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:
csharp1private 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:
csharp1private static TransitTimeRange GetTransitTimeRange(2 ServiceType serviceType, bool isInternational)3{4 var baseRange = DomesticTransitTimes[serviceType];56 if (!isInternational)7 return baseRange;89 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:
csharp1private static DateOnly AddBusinessDays(DateOnly startDate, int businessDays)2{3 var current = startDate;4 var added = 0;56 while (added < businessDays)7 {8 current = current.AddDays(1);910 if (current.DayOfWeek is not DayOfWeek.Saturday11 and not DayOfWeek.Sunday)12 {13 added++;14 }15 }1617 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:
csharp1public DeliveryEstimateResult Calculate(Parcel parcel)2{3 var isInternational = IsInternational(parcel);4 var transitRange = GetTransitTimeRange(parcel.ServiceType, isInternational);5 var startDate = DateOnly.FromDateTime(parcel.CreatedAt);67 var earliest = AddBusinessDays(startDate, transitRange.MinDays);8 var latest = AddBusinessDays(startDate, transitRange.MaxDays);9 var confidence = DetermineConfidence(parcel.Status);1011 return new DeliveryEstimateResult12 {13 EarliestDelivery = earliest,14 LatestDelivery = latest,15 Confidence = confidence16 };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:
csharp1private static DeliveryConfidenceLevel DetermineConfidence(ParcelStatus status)2{3 return status switch4 {5 ParcelStatus.OutForDelivery => DeliveryConfidenceLevel.High,6 ParcelStatus.Delivered => DeliveryConfidenceLevel.High,7 ParcelStatus.InTransit => DeliveryConfidenceLevel.Medium,8 _ => DeliveryConfidenceLevel.Low9 };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:
csharp1public DeliveryEstimateResult Recalculate(Parcel parcel, DateOnly fromDate)2{3 var isInternational = IsInternational(parcel);4 var transitRange = GetTransitTimeRange(parcel.ServiceType, isInternational);56 // Use remaining transit time from the new starting point7 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);1112 var earliest = AddBusinessDays(fromDate, remainingMin);13 var latest = AddBusinessDays(fromDate, remainingMax);14 var confidence = DetermineConfidence(parcel.Status);1516 return new DeliveryEstimateResult17 {18 EarliestDelivery = earliest,19 LatestDelivery = latest,20 Confidence = confidence21 };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:
csharp1private static int CountBusinessDays(DateOnly from, DateOnly to)2{3 var count = 0;4 var current = from;56 while (current < to)7 {8 current = current.AddDays(1);910 if (current.DayOfWeek is not DayOfWeek.Saturday11 and not DayOfWeek.Sunday)12 {13 count++;14 }15 }1617 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:
csharp1builder.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:
csharp1public class DeliveryEstimationService : IDeliveryEstimationService2{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 };1011 private const int InternationalMinAdditionalDays = 3;12 private const int InternationalMaxAdditionalDays = 5;1314 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);1920 return new DeliveryEstimateResult21 {22 EarliestDelivery = AddBusinessDays(startDate, transitRange.MinDays),23 LatestDelivery = AddBusinessDays(startDate, transitRange.MaxDays),24 Confidence = DetermineConfidence(parcel.Status)25 };26 }2728 public DeliveryEstimateResult Recalculate(Parcel parcel, DateOnly fromDate)29 {30 var isInternational = IsInternational(parcel);31 var transitRange = GetTransitTimeRange(parcel.ServiceType, isInternational);3233 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);3738 return new DeliveryEstimateResult39 {40 EarliestDelivery = AddBusinessDays(fromDate, remainingMin),41 LatestDelivery = AddBusinessDays(fromDate, remainingMax),42 Confidence = DetermineConfidence(parcel.Status)43 };44 }4546 private static bool IsInternational(Parcel parcel) =>47 !string.Equals(48 parcel.ShipperAddress.CountryCode,49 parcel.RecipientAddress.CountryCode,50 StringComparison.OrdinalIgnoreCase);5152 private static TransitTimeRange GetTransitTimeRange(53 ServiceType serviceType, bool isInternational)54 {55 var baseRange = DomesticTransitTimes[serviceType];56 return isInternational57 ? new TransitTimeRange(58 baseRange.MinDays + InternationalMinAdditionalDays,59 baseRange.MaxDays + InternationalMaxAdditionalDays)60 : baseRange;61 }6263 private static DeliveryConfidenceLevel DetermineConfidence(ParcelStatus status) =>64 status switch65 {66 ParcelStatus.OutForDelivery => DeliveryConfidenceLevel.High,67 ParcelStatus.Delivered => DeliveryConfidenceLevel.High,68 ParcelStatus.InTransit => DeliveryConfidenceLevel.Medium,69 _ => DeliveryConfidenceLevel.Low70 };7172 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.Saturday80 and not DayOfWeek.Sunday)81 added++;82 }83 return current;84 }8586 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.Saturday94 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:
csharp1[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)); // Monday1011 var result = service.Calculate(parcel);1213 Assert.Equal(new DateOnly(2025, 1, 9), result.EarliestDelivery); // Thursday14 Assert.Equal(new DateOnly(2025, 1, 13), result.LatestDelivery); // Monday15}1617[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)); // Monday2627 var result = service.Calculate(parcel);2829 // Express (1-2) + International (3-5) = 4-7 business days30 Assert.Equal(new DateOnly(2025, 1, 10), result.EarliestDelivery); // Friday31 Assert.Equal(new DateOnly(2025, 1, 15), result.LatestDelivery); // Wednesday32}
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
DeliveryEstimateResultmodel 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
Calculatemethod for initial estimates and aRecalculatemethod for updates - An
IDeliveryEstimationServiceinterface for dependency injection - Unit tests that verify the calculation logic without any infrastructure