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 EstimatedDeliveryDto
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 IParcelService _parcelService;
6
7 public DeliveryEstimateController(IParcelService parcelService)
8 {
9 _parcelService = parcelService;
10 }
11
12 [HttpGet]
13 public async Task<ActionResult<EstimatedDeliveryDto>> GetEstimate(Guid id)
14 {
15 var estimate = await _parcelService.CalculateEstimatedDeliveryAsync(id);
16
17 if (estimate is null)
18 return NotFound();
19
20 return Ok(estimate);
21 }
22}

The controller delegates to the service layer, which handles loading the parcel, performing the calculation, and mapping to the DTO. If the parcel does not exist, the service returns null and the controller returns 404.

Service Layer Responsibility

The IParcelService encapsulates all parcel-related business logic, including delivery estimation. The service loads the parcel with both addresses (needed for the domestic/international check), calls the IDeliveryEstimationService, and maps the result to the response DTO.

This separation of concerns keeps controllers thin and focused on HTTP concerns while the service layer handles business logic and data access.

The EstimatedDeliveryDto

The response DTO encapsulates the delivery estimate information:

csharp
1public class EstimatedDeliveryDto
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 IParcelService.CalculateEstimatedDeliveryAsync method loads the parcel with addresses, calls the IDeliveryEstimationService, and maps the result to this DTO:

csharp
1public async Task<EstimatedDeliveryDto?> CalculateEstimatedDeliveryAsync(Guid parcelId)
2{
3 var parcel = await _context.Parcels
4 .Include(p => p.ShipperAddress)
5 .Include(p => p.RecipientAddress)
6 .FirstOrDefaultAsync(p => p.Id == parcelId);
7
8 if (parcel is null)
9 return null;
10
11 var estimate = _estimationService.Calculate(parcel);
12
13 var isInternational = !string.Equals(
14 parcel.ShipperAddress.CountryCode,
15 parcel.RecipientAddress.CountryCode,
16 StringComparison.OrdinalIgnoreCase);
17
18 return new EstimatedDeliveryDto
19 {
20 EarliestDelivery = estimate.EarliestDelivery,
21 LatestDelivery = estimate.LatestDelivery,
22 Confidence = estimate.Confidence.ToString(),
23 ServiceType = parcel.ServiceType.ToString(),
24 IsInternational = isInternational
25 };
26}

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<EstimatedDeliveryDto>> Recalculate(Guid id)
3{
4 var estimate = await _parcelService.RecalculateEstimatedDeliveryAsync(id);
5
6 if (estimate is null)
7 return NotFound();
8
9 return Ok(estimate);
10}

The service method handles the recalculation logic:

csharp
1public async Task<EstimatedDeliveryDto?> RecalculateEstimatedDeliveryAsync(Guid parcelId)
2{
3 var parcel = await _context.Parcels
4 .Include(p => p.ShipperAddress)
5 .Include(p => p.RecipientAddress)
6 .Include(p => p.TrackingEvents.OrderByDescending(e => e.Timestamp))
7 .FirstOrDefaultAsync(p => p.Id == parcelId);
8
9 if (parcel is null)
10 return null;
11
12 var latestEvent = parcel.TrackingEvents.FirstOrDefault();
13 var fromDate = latestEvent is not null
14 ? DateOnly.FromDateTime(latestEvent.Timestamp)
15 : DateOnly.FromDateTime(DateTime.UtcNow);
16
17 var estimate = _estimationService.Recalculate(parcel, fromDate);
18
19 parcel.EstimatedDeliveryDate = estimate.LatestDelivery
20 .ToDateTime(TimeOnly.MinValue);
21 parcel.UpdatedAt = DateTime.UtcNow;
22
23 await _context.SaveChangesAsync();
24
25 var isInternational = !string.Equals(
26 parcel.ShipperAddress.CountryCode,
27 parcel.RecipientAddress.CountryCode,
28 StringComparison.OrdinalIgnoreCase);
29
30 return new EstimatedDeliveryDto
31 {
32 EarliestDelivery = estimate.EarliestDelivery,
33 LatestDelivery = estimate.LatestDelivery,
34 Confidence = estimate.Confidence.ToString(),
35 ServiceType = parcel.ServiceType.ToString(),
36 IsInternational = isInternational
37 };
38}

The service method does more than the calculate method:

  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 service:

csharp
1if (parcel.Status == ParcelStatus.Delivered && parcel.ActualDeliveryDate.HasValue)
2{
3 var deliveryDate = DateOnly.FromDateTime(parcel.ActualDeliveryDate.Value);
4 return new EstimatedDeliveryDto
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 EstimatedDeliveryDto? DeliveryEstimate { get; init; }
6}

Then populate it in the parcel detail service method:

csharp
1var estimate = _estimationService.Calculate(parcel);
2
3var isInternational = !string.Equals(
4 parcel.ShipperAddress.CountryCode,
5 parcel.RecipientAddress.CountryCode,
6 StringComparison.OrdinalIgnoreCase);
7
8var parcelResponse = new ParcelDetailResponse
9{
10 // ... existing mapping ...
11 DeliveryEstimate = new EstimatedDeliveryDto
12 {
13 EarliestDelivery = estimate.EarliestDelivery,
14 LatestDelivery = estimate.LatestDelivery,
15 Confidence = estimate.Confidence.ToString(),
16 ServiceType = parcel.ServiceType.ToString(),
17 IsInternational = isInternational
18 }
19};

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<EstimatedDeliveryDto>();
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<EstimatedDeliveryDto>();
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:

  • An EstimatedDeliveryDto DTO with time window, confidence, and context fields
  • Service layer methods in IParcelService that encapsulate delivery estimation logic
  • 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