Time Windows, Confidence, and Recalculation Endpoints
With the DeliveryEstimationService built, you now need endpoints that expose the delivery estimate to API consumers. In this presentation, you will build a GET endpoint that returns the delivery time window, a PUT endpoint that triggers recalculation after exceptions, and a structured API response that includes the confidence level.
Designing the API Response DTO
The API response maps directly from DeliveryEstimateResult but uses a format suitable for JSON serialization:
csharp1public class DeliveryEstimateResponse2{3 public DateOnly EarliestDelivery { get; init; }4 public DateOnly LatestDelivery { get; init; }5 public string Confidence { get; init; } = string.Empty;6 public string ServiceType { get; init; } = string.Empty;7 public bool IsInternational { get; init; }8}
The response includes ServiceType and IsInternational as context fields. API consumers should not need to make a second request to understand why the estimate window is wide or narrow. Including these fields makes the response self-describing.
The Confidence field is a string rather than an integer or enum value. Strings like "High", "Medium", and "Low" are immediately understandable to frontend developers without consulting the API documentation.
A Sample Response
A typical response for a domestic Standard parcel that is in transit:
json1{2 "earliestDelivery": "2025-01-09",3 "latestDelivery": "2025-01-13",4 "confidence": "Medium",5 "serviceType": "Standard",6 "isInternational": false7}
And for an international Express parcel that just had its label created:
json1{2 "earliestDelivery": "2025-01-10",3 "latestDelivery": "2025-01-15",4 "confidence": "Low",5 "serviceType": "Express",6 "isInternational": true7}
Notice how the international shipment has a wider window (4-7 business days for Express plus international surcharge) and lower confidence (label just created, parcel has not entered the network).
The GET Delivery Estimate Endpoint
The GET endpoint retrieves the parcel and runs the calculation:
csharp1[ApiController]2[Route("api/parcels/{id:guid}/delivery-estimate")]3public class DeliveryEstimateController : ControllerBase4{5 private readonly ParcelTrackingDbContext _context;6 private readonly IDeliveryEstimationService _estimationService;78 public DeliveryEstimateController(9 ParcelTrackingDbContext context,10 IDeliveryEstimationService estimationService)11 {12 _context = context;13 _estimationService = estimationService;14 }1516 [HttpGet]17 public async Task<ActionResult<DeliveryEstimateResponse>> GetEstimate(Guid id)18 {19 var parcel = await _context.Parcels20 .Include(p => p.ShipperAddress)21 .Include(p => p.RecipientAddress)22 .FirstOrDefaultAsync(p => p.Id == id);2324 if (parcel is null)25 return NotFound();2627 var estimate = _estimationService.Calculate(parcel);2829 return Ok(MapToResponse(parcel, estimate));30 }31}
The endpoint loads the parcel with both addresses (needed for the domestic/international check), calls the service, and maps the result to the response DTO. If the parcel does not exist, it returns 404.
Why Include Addresses?
The Include calls for ShipperAddress and RecipientAddress are necessary because the estimation service compares their country codes. Without eager loading, those navigation properties would be null and the service would fail.
This is a common pattern in service-oriented architectures: the controller is responsible for loading the data the service needs. The service itself does not access the database.
Mapping to the Response
The mapping method converts the internal result to the API response:
csharp1private static DeliveryEstimateResponse MapToResponse(2 Parcel parcel, DeliveryEstimateResult estimate)3{4 var isInternational = !string.Equals(5 parcel.ShipperAddress.CountryCode,6 parcel.RecipientAddress.CountryCode,7 StringComparison.OrdinalIgnoreCase);89 return new DeliveryEstimateResponse10 {11 EarliestDelivery = estimate.EarliestDelivery,12 LatestDelivery = estimate.LatestDelivery,13 Confidence = estimate.Confidence.ToString(),14 ServiceType = parcel.ServiceType.ToString(),15 IsInternational = isInternational16 };17}
Using .ToString() on enums produces clean string values like "High" and "Standard". The default System.Text.Json serializer handles DateOnly as "2025-01-09" format, which is the ISO 8601 date format that JavaScript can parse directly.
The PUT Recalculation Endpoint
When an exception or delay occurs, the client calls the recalculation endpoint to update the estimate:
csharp1[HttpPut("recalculate")]2public async Task<ActionResult<DeliveryEstimateResponse>> Recalculate(Guid id)3{4 var parcel = await _context.Parcels5 .Include(p => p.ShipperAddress)6 .Include(p => p.RecipientAddress)7 .Include(p => p.TrackingEvents.OrderByDescending(e => e.Timestamp))8 .FirstOrDefaultAsync(p => p.Id == id);910 if (parcel is null)11 return NotFound();1213 var latestEvent = parcel.TrackingEvents.FirstOrDefault();14 var fromDate = latestEvent is not null15 ? DateOnly.FromDateTime(latestEvent.Timestamp)16 : DateOnly.FromDateTime(DateTime.UtcNow);1718 var estimate = _estimationService.Recalculate(parcel, fromDate);1920 parcel.EstimatedDeliveryDate = estimate.LatestDelivery21 .ToDateTime(TimeOnly.MinValue);22 parcel.UpdatedAt = DateTime.UtcNow;2324 await _context.SaveChangesAsync();2526 return Ok(MapToResponse(parcel, estimate));27}
This endpoint does more than the GET:
- Loads tracking events to find the most recent one
- Determines the recalculation start date from the latest event's timestamp
- Calls Recalculate with the new start date
- Updates the parcel in the database with the new estimated delivery date
- Saves and returns the updated estimate
Why Use the Latest Event Date?
The recalculation starts from the most recent tracking event because that represents the last known state of the parcel. If a parcel had an exception on Wednesday, recalculating from Wednesday (not from the original ship date) gives the most accurate estimate of the remaining transit time.
If there are no tracking events (unlikely in practice but possible), the method falls back to the current date.
Why Update EstimatedDeliveryDate?
The parcel's EstimatedDeliveryDate field stores the latest estimate in the database. Other parts of the API (parcel detail endpoints, search results) display this field. Updating it ensures that the most recent estimate is always available without recalculating.
We store the LatestDelivery (worst case) as the estimated delivery date because it sets realistic expectations. Under-promising and over-delivering is better than the reverse.
Confidence in Detail
Confidence levels map to actionable information for API consumers:
High Confidence
Returned when the parcel status is OutForDelivery or Delivered. The delivery window is narrow and reliable.
json1{2 "earliestDelivery": "2025-01-10",3 "latestDelivery": "2025-01-10",4 "confidence": "High",5 "serviceType": "Express",6 "isInternational": false7}
For an OutForDelivery parcel, the earliest and latest dates are often the same day: today. The parcel is on the truck and barring an unusual event, it will be delivered.
Medium Confidence
Returned when the parcel is InTransit. The estimate is reasonable but subject to change.
json1{2 "earliestDelivery": "2025-01-09",3 "latestDelivery": "2025-01-13",4 "confidence": "Medium",5 "serviceType": "Standard",6 "isInternational": false7}
Frontend applications might display this as "Expected Jan 9-13" with a note that the estimate may change.
Low Confidence
Returned for LabelCreated, PickedUp, Exception, and Returned statuses. The estimate is tentative.
json1{2 "earliestDelivery": "2025-01-14",3 "latestDelivery": "2025-01-20",4 "confidence": "Low",5 "serviceType": "Economy",6 "isInternational": true7}
Frontend applications might display this as "Estimated Jan 14-20" with a disclaimer that the estimate is preliminary.
Handling Edge Cases
Delivered Parcels
If someone requests a delivery estimate for an already-delivered parcel, the service still returns a result. The confidence is High and both dates reflect the actual delivery date. There is no special handling needed.
csharp1// The Calculate method works for delivered parcels too.2// The confidence will be High (correct), and the dates3// reflect when the parcel was originally estimated to arrive.
If you wanted the dates to reflect the actual delivery date instead, you could add a check in the controller:
csharp1if (parcel.Status == ParcelStatus.Delivered && parcel.ActualDeliveryDate.HasValue)2{3 var deliveryDate = DateOnly.FromDateTime(parcel.ActualDeliveryDate.Value);4 return Ok(new DeliveryEstimateResponse5 {6 EarliestDelivery = deliveryDate,7 LatestDelivery = deliveryDate,8 Confidence = "High",9 ServiceType = parcel.ServiceType.ToString(),10 IsInternational = isInternational11 });12}
This is a product decision. Both approaches are valid.
Returned Parcels
For parcels with Returned status, the delivery estimate is meaningless since the parcel is going back to the sender. The service still returns a result with Low confidence. The frontend should interpret the Returned status and display an appropriate message instead of the delivery window.
Unknown Service Types
If a new ServiceType is added but not included in the transit time dictionary, the dictionary lookup will throw a KeyNotFoundException. This is intentional. Failing loudly when configuration is missing is better than returning incorrect data. The fix is to add the new service type to the dictionary.
Recalculation Scenarios
Scenario 1: Weather Delay
A Standard domestic parcel shipped on Monday hits a weather delay on Wednesday:
1Original estimate: Thursday (earliest) to Monday (latest)2Recalculation from Wednesday:3 - Elapsed: 2 business days4 - Remaining min: max(1, 3-2) = 15 - Remaining max: max(1, 5-2) = 36 - New estimate: Thursday (earliest) to Monday (latest)
The estimate may not change much if the delay happened early in transit.
Scenario 2: Failed Delivery Attempt
An Express domestic parcel with an original estimate of Tuesday hits a failed delivery attempt on Tuesday:
1Original estimate: Tuesday (earliest) to Wednesday (latest)2Recalculation from Tuesday:3 - Elapsed: could be 1-2 business days4 - Remaining min: max(1, 1-elapsed) = 15 - Remaining max: max(1, 2-elapsed) = 16 - New estimate: Wednesday (earliest) to Wednesday (latest)
The Math.Max(1, ...) floor ensures the new estimate is at least one business day out.
Scenario 3: Customs Hold (International)
An Economy international parcel hits a customs hold on day 4:
1Original estimate: 8-12 business days from ship date2Recalculation from day 4:3 - Elapsed: 4 business days4 - Remaining min: max(1, 8-4) = 45 - Remaining max: max(1, 12-4) = 86 - New estimate: 4-8 business days from current date
The customs hold extends the delivery window because the recalculation starts from the current date with the remaining transit time.
Integrating with Existing Endpoints
The delivery estimate fields already exist on the Parcel entity. Existing endpoints that return parcel details (GET /api/parcels/{id}) already include EstimatedDeliveryDate. The recalculation endpoint keeps that field current.
If you want to include the full delivery window in the parcel detail response, add the estimate fields to the parcel detail DTO:
csharp1public class ParcelDetailResponse2{3 // ... existing fields ...45 public DeliveryEstimateResponse? DeliveryEstimate { get; init; }6}
Then populate it in the parcel detail endpoint:
csharp1var estimate = _estimationService.Calculate(parcel);2var parcelResponse = new ParcelDetailResponse3{4 // ... existing mapping ...5 DeliveryEstimate = MapToResponse(parcel, estimate)6};
This pattern of enriching existing responses with calculated data is common. The calculation is fast (no database calls) and provides valuable context.
Error Responses
The endpoints return standard HTTP status codes:
| Status Code | Condition |
|---|---|
| 200 OK | Estimate calculated successfully |
| 404 Not Found | Parcel with the given ID does not exist |
No request body is needed for either endpoint. The GET endpoint takes no parameters beyond the parcel ID in the route. The PUT endpoint also needs only the parcel ID because it determines the recalculation start date from the most recent tracking event.
If the parcel has no addresses loaded (a data integrity issue), the service will throw a NullReferenceException. The global exception handler (configured in earlier topics) catches this and returns a 500 response. There is no need for the controller to handle this case explicitly.
Testing the Endpoints
Integration tests for these endpoints follow the same pattern as other endpoints in the course:
csharp1[Fact]2public async Task GetEstimate_ExistingParcel_ReturnsDeliveryWindow()3{4 // Arrange5 var parcel = await SeedParcel(ServiceType.Standard, "US", "US");67 // Act8 var response = await _client.GetAsync(9 $"/api/parcels/{parcel.Id}/delivery-estimate");1011 // Assert12 response.StatusCode.Should().Be(HttpStatusCode.OK);1314 var estimate = await response.Content15 .ReadFromJsonAsync<DeliveryEstimateResponse>();16 estimate!.EarliestDelivery.Should().BeBefore(estimate.LatestDelivery);17 estimate.Confidence.Should().Be("Low");18 estimate.IsInternational.Should().BeFalse();19}2021[Fact]22public async Task Recalculate_AfterException_UpdatesParcelEstimate()23{24 // Arrange25 var parcel = await SeedParcel(ServiceType.Express, "US", "GB");26 await AddTrackingEvent(parcel.Id, EventType.Exception);2728 // Act29 var response = await _client.PutAsync(30 $"/api/parcels/{parcel.Id}/delivery-estimate/recalculate",31 null);3233 // Assert34 response.StatusCode.Should().Be(HttpStatusCode.OK);3536 var estimate = await response.Content37 .ReadFromJsonAsync<DeliveryEstimateResponse>();38 estimate!.IsInternational.Should().BeTrue();39 estimate.Confidence.Should().Be("Low");40}
These tests verify the full request/response cycle including database loading, service calculation, and JSON serialization. The SeedParcel and AddTrackingEvent helper methods set up the test data in the in-memory database.
Summary
In this presentation, you built:
- A
DeliveryEstimateResponseDTO with time window, confidence, and context fields - A GET endpoint that returns the delivery estimate for a parcel
- A PUT endpoint that recalculates the estimate after exceptions and updates the database
- Confidence levels that communicate estimate reliability to API consumers
- Edge case handling for delivered parcels, returned parcels, and recalculation scenarios
- Integration tests that verify the full endpoint behavior