16 minlesson

Time Windows, Confidence, and Recalculation Endpoints

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:

csharp
1public class DeliveryEstimateResponse
2{
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:

json
1{
2 "earliestDelivery": "2025-01-09",
3 "latestDelivery": "2025-01-13",
4 "confidence": "Medium",
5 "serviceType": "Standard",
6 "isInternational": false
7}

And for an international Express parcel that just had its label created:

json
1{
2 "earliestDelivery": "2025-01-10",
3 "latestDelivery": "2025-01-15",
4 "confidence": "Low",
5 "serviceType": "Express",
6 "isInternational": true
7}

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:

csharp
1[ApiController]
2[Route("api/parcels/{id:guid}/delivery-estimate")]
3public class DeliveryEstimateController : ControllerBase
4{
5 private readonly ParcelTrackingDbContext _context;
6 private readonly IDeliveryEstimationService _estimationService;
7
8 public DeliveryEstimateController(
9 ParcelTrackingDbContext context,
10 IDeliveryEstimationService estimationService)
11 {
12 _context = context;
13 _estimationService = estimationService;
14 }
15
16 [HttpGet]
17 public async Task<ActionResult<DeliveryEstimateResponse>> GetEstimate(Guid id)
18 {
19 var parcel = await _context.Parcels
20 .Include(p => p.ShipperAddress)
21 .Include(p => p.RecipientAddress)
22 .FirstOrDefaultAsync(p => p.Id == id);
23
24 if (parcel is null)
25 return NotFound();
26
27 var estimate = _estimationService.Calculate(parcel);
28
29 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:

csharp
1private 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);
8
9 return new DeliveryEstimateResponse
10 {
11 EarliestDelivery = estimate.EarliestDelivery,
12 LatestDelivery = estimate.LatestDelivery,
13 Confidence = estimate.Confidence.ToString(),
14 ServiceType = parcel.ServiceType.ToString(),
15 IsInternational = isInternational
16 };
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:

csharp
1[HttpPut("recalculate")]
2public async Task<ActionResult<DeliveryEstimateResponse>> Recalculate(Guid id)
3{
4 var parcel = await _context.Parcels
5 .Include(p => p.ShipperAddress)
6 .Include(p => p.RecipientAddress)
7 .Include(p => p.TrackingEvents.OrderByDescending(e => e.Timestamp))
8 .FirstOrDefaultAsync(p => p.Id == id);
9
10 if (parcel is null)
11 return NotFound();
12
13 var latestEvent = parcel.TrackingEvents.FirstOrDefault();
14 var fromDate = latestEvent is not null
15 ? DateOnly.FromDateTime(latestEvent.Timestamp)
16 : DateOnly.FromDateTime(DateTime.UtcNow);
17
18 var estimate = _estimationService.Recalculate(parcel, fromDate);
19
20 parcel.EstimatedDeliveryDate = estimate.LatestDelivery
21 .ToDateTime(TimeOnly.MinValue);
22 parcel.UpdatedAt = DateTime.UtcNow;
23
24 await _context.SaveChangesAsync();
25
26 return Ok(MapToResponse(parcel, estimate));
27}

This endpoint does more than the GET:

  1. Loads tracking events to find the most recent one
  2. Determines the recalculation start date from the latest event's timestamp
  3. Calls Recalculate with the new start date
  4. Updates the parcel in the database with the new estimated delivery date
  5. 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.

json
1{
2 "earliestDelivery": "2025-01-10",
3 "latestDelivery": "2025-01-10",
4 "confidence": "High",
5 "serviceType": "Express",
6 "isInternational": false
7}

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.

json
1{
2 "earliestDelivery": "2025-01-09",
3 "latestDelivery": "2025-01-13",
4 "confidence": "Medium",
5 "serviceType": "Standard",
6 "isInternational": false
7}

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.

json
1{
2 "earliestDelivery": "2025-01-14",
3 "latestDelivery": "2025-01-20",
4 "confidence": "Low",
5 "serviceType": "Economy",
6 "isInternational": true
7}

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.

csharp
1// The Calculate method works for delivered parcels too.
2// The confidence will be High (correct), and the dates
3// 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:

csharp
1if (parcel.Status == ParcelStatus.Delivered && parcel.ActualDeliveryDate.HasValue)
2{
3 var deliveryDate = DateOnly.FromDateTime(parcel.ActualDeliveryDate.Value);
4 return Ok(new DeliveryEstimateResponse
5 {
6 EarliestDelivery = deliveryDate,
7 LatestDelivery = deliveryDate,
8 Confidence = "High",
9 ServiceType = parcel.ServiceType.ToString(),
10 IsInternational = isInternational
11 });
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 days
4 - Remaining min: max(1, 3-2) = 1
5 - Remaining max: max(1, 5-2) = 3
6 - 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 days
4 - Remaining min: max(1, 1-elapsed) = 1
5 - Remaining max: max(1, 2-elapsed) = 1
6 - 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 date
2Recalculation from day 4:
3 - Elapsed: 4 business days
4 - Remaining min: max(1, 8-4) = 4
5 - Remaining max: max(1, 12-4) = 8
6 - 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:

csharp
1public class ParcelDetailResponse
2{
3 // ... existing fields ...
4
5 public DeliveryEstimateResponse? DeliveryEstimate { get; init; }
6}

Then populate it in the parcel detail endpoint:

csharp
1var estimate = _estimationService.Calculate(parcel);
2var parcelResponse = new ParcelDetailResponse
3{
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 CodeCondition
200 OKEstimate calculated successfully
404 Not FoundParcel 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:

csharp
1[Fact]
2public async Task GetEstimate_ExistingParcel_ReturnsDeliveryWindow()
3{
4 // Arrange
5 var parcel = await SeedParcel(ServiceType.Standard, "US", "US");
6
7 // Act
8 var response = await _client.GetAsync(
9 $"/api/parcels/{parcel.Id}/delivery-estimate");
10
11 // Assert
12 response.StatusCode.Should().Be(HttpStatusCode.OK);
13
14 var estimate = await response.Content
15 .ReadFromJsonAsync<DeliveryEstimateResponse>();
16 estimate!.EarliestDelivery.Should().BeBefore(estimate.LatestDelivery);
17 estimate.Confidence.Should().Be("Low");
18 estimate.IsInternational.Should().BeFalse();
19}
20
21[Fact]
22public async Task Recalculate_AfterException_UpdatesParcelEstimate()
23{
24 // Arrange
25 var parcel = await SeedParcel(ServiceType.Express, "US", "GB");
26 await AddTrackingEvent(parcel.Id, EventType.Exception);
27
28 // Act
29 var response = await _client.PutAsync(
30 $"/api/parcels/{parcel.Id}/delivery-estimate/recalculate",
31 null);
32
33 // Assert
34 response.StatusCode.Should().Be(HttpStatusCode.OK);
35
36 var estimate = await response.Content
37 .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 DeliveryEstimateResponse DTO 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