18 minlesson

Building the Confirmation Endpoint

Building the Confirmation Endpoint

In this presentation, we build the POST endpoint that confirms parcel delivery. We introduce a service layer that encapsulates business logic, coordinates multiple operations (validating the parcel status, checking for duplicate confirmations, creating a DeliveryConfirmation record, auto-creating a "Delivered" tracking event, and updating the parcel's status and actual delivery date), and keeps the endpoint handler clean and focused on HTTP concerns.

The Request and Response DTOs

Before writing the service and endpoint, define the data shapes. The request DTO captures the proof-of-delivery data from the delivery driver:

csharp
1public class CreateDeliveryConfirmationRequest
2{
3 public required string ReceivedBy { get; init; }
4 public required string DeliveryLocation { get; init; }
5 public string? SignatureImage { get; init; }
6 public required DateTime DeliveredAt { get; init; }
7}

The ReceivedBy and DeliveryLocation fields are required. A delivery must always record who received the package and where it was left. The SignatureImage is optional because not all deliveries require a signature. The DeliveredAt timestamp comes from the driver's device.

The response DTO returns the created confirmation along with parcel context:

csharp
1public class DeliveryConfirmationDto
2{
3 public Guid Id { get; init; }
4 public string TrackingNumber { get; init; } = string.Empty;
5 public string ReceivedBy { get; init; } = string.Empty;
6 public string DeliveryLocation { get; init; } = string.Empty;
7 public bool HasSignature { get; init; }
8 public DateTime DeliveredAt { get; init; }
9 public DateTime CreatedAt { get; init; }
10}

Notice HasSignature instead of returning the full base64 string in the POST response. The signature data can be large, and the caller already has it since they just sent it. The GET endpoint returns the full signature when needed.

The Service Interface

Following the service layer pattern, we define an interface that encapsulates all delivery confirmation operations:

csharp
1public interface IDeliveryConfirmationService
2{
3 Task<DeliveryConfirmationDto> CreateAsync(Guid parcelId, CreateDeliveryConfirmationRequest request);
4 Task<DeliveryConfirmationDetailDto?> GetDetailByParcelIdAsync(Guid parcelId);
5}

The interface defines two operations: creating a confirmation and retrieving detailed confirmation by parcel ID. Notice the service works with Guid parcelId, not tracking numbers. The endpoint handles the tracking number lookup and passes the parcel ID to the service. This keeps the service focused on business logic, not HTTP routing concerns. The GetDetailByParcelIdAsync method will be implemented in the next lesson.

Service Implementation: CreateAsync

The DeliveryConfirmationService implements the interface and encapsulates all business logic for delivery confirmation. Let's examine the CreateAsync method step by step.

Step 1: Load the Parcel

The first step is to find the parcel by its ID and validate it exists:

csharp
1public class DeliveryConfirmationService : IDeliveryConfirmationService
2{
3 private readonly ParcelTrackingDbContext _db;
4
5 public DeliveryConfirmationService(ParcelTrackingDbContext db)
6 {
7 _db = db;
8 }
9
10 public async Task<DeliveryConfirmationDto> CreateAsync(
11 Guid parcelId,
12 CreateDeliveryConfirmationRequest request)
13 {
14 var parcel = await _db.Parcels
15 .Include(p => p.RecipientAddress)
16 .FirstOrDefaultAsync(p => p.Id == parcelId);
17
18 if (parcel is null)
19 {
20 throw new InvalidOperationException($"Parcel with ID '{parcelId}' not found.");
21 }
22
23 // Remaining validation and creation logic follows...
24 }
25}

Notice the service throws exceptions instead of returning HTTP results. The service layer doesn't know about HTTP status codes. The endpoint handler catches exceptions and translates them to appropriate HTTP responses. We eager-load the RecipientAddress because we need its location data for the tracking event.

Step 2: Validate the Parcel Status

Only parcels with status InTransit or OutForDelivery can be confirmed as delivered:

csharp
1if (parcel.Status != ParcelStatus.InTransit &&
2 parcel.Status != ParcelStatus.OutForDelivery)
3{
4 throw new InvalidOperationException(
5 $"Cannot confirm delivery. Parcel status is '{parcel.Status}'. " +
6 "Parcel must be in 'InTransit' or 'OutForDelivery' status.");
7}

The service throws an exception with a descriptive message. The endpoint handler will translate this to a 400 Bad Request response with the exception message as the error detail.

Why not use a simple list check? With only two valid statuses, explicit comparisons are clearer than new[] { ... }.Contains(...). If the valid set grows in the future, you can refactor then.

Step 3: Check for Duplicate Confirmations

A parcel can only be delivered once. Before creating a new record, check if one already exists:

csharp
1var existingConfirmation = await _db.DeliveryConfirmations
2 .AnyAsync(dc => dc.ParcelId == parcel.Id);
3
4if (existingConfirmation)
5{
6 throw new InvalidOperationException(
7 "Delivery confirmation already exists for this parcel.");
8}

Using AnyAsync is more efficient than FirstOrDefaultAsync here because we only need to know whether a record exists, not retrieve its data. The database can short-circuit after finding the first match.

The service throws an exception for duplicate confirmations. The endpoint handler will catch this specific message pattern and translate it to a 409 Conflict response.

Step 4: Create the DeliveryConfirmation Record

With all validations passed, create the confirmation entity:

csharp
1var confirmation = new DeliveryConfirmation
2{
3 Id = Guid.NewGuid(),
4 ParcelId = parcel.Id,
5 ReceivedBy = request.ReceivedBy,
6 DeliveryLocation = request.DeliveryLocation,
7 SignatureImage = request.SignatureImage,
8 DeliveredAt = request.DeliveredAt,
9 CreatedAt = DateTime.UtcNow
10};
11
12_db.DeliveryConfirmations.Add(confirmation);

Each field maps directly from the request DTO to the entity. The Id is generated server-side as a GUID. The ParcelId links the confirmation to the parcel. CreatedAt records when the confirmation was processed, which may differ from DeliveredAt (when the package was physically delivered).

Step 5: Auto-Create the Tracking Event

Every status change in the parcel's lifecycle should be recorded as a tracking event. Delivery is no exception:

csharp
1var trackingEvent = new TrackingEvent
2{
3 Id = Guid.NewGuid(),
4 ParcelId = parcel.Id,
5 EventType = EventType.Delivered,
6 Timestamp = request.DeliveredAt,
7 Description = $"Delivered to {request.ReceivedBy} at {request.DeliveryLocation}",
8 LocationCity = parcel.RecipientAddress?.City,
9 LocationState = parcel.RecipientAddress?.State,
10 LocationCountry = parcel.RecipientAddress?.CountryCode,
11 CreatedAt = DateTime.UtcNow
12};
13
14_db.TrackingEvents.Add(trackingEvent);

The tracking event uses the same DeliveredAt timestamp from the request, keeping the timeline consistent. The description is human-readable, combining the recipient name and location. Location fields come from the recipient address since that is where delivery occurred.

This auto-creation pattern keeps the tracking history complete without requiring the API caller to make a separate request to create the event.

Step 6: Update the Parcel

The final data modification updates the parcel's status and delivery date:

csharp
1parcel.Status = ParcelStatus.Delivered;
2parcel.ActualDeliveryDate = request.DeliveredAt;
3parcel.UpdatedAt = DateTime.UtcNow;

Because we loaded the parcel in Step 1, EF Core is already tracking it. Modifying its properties marks it as Modified in the change tracker. No explicit Update call is needed.

Setting ActualDeliveryDate enables the on-time calculation in the GET endpoint. The UpdatedAt timestamp records when the parcel entity was last modified.

Step 7: Save All Changes and Return DTO

All three modifications (confirmation, tracking event, parcel update) are saved in a single call, and the result is mapped to a DTO:

csharp
1await _db.SaveChangesAsync();
2
3return new DeliveryConfirmationDto
4{
5 Id = confirmation.Id,
6 TrackingNumber = parcel.TrackingNumber,
7 ReceivedBy = confirmation.ReceivedBy,
8 DeliveryLocation = confirmation.DeliveryLocation,
9 HasSignature = confirmation.SignatureImage is not null,
10 DeliveredAt = confirmation.DeliveredAt,
11 CreatedAt = confirmation.CreatedAt
12};

EF Core wraps all pending changes in a database transaction. Either all three writes succeed, or none of them are applied. This guarantees transactional consistency without explicit transaction management code.

If SaveChangesAsync throws a DbUpdateException, the database rolls back all changes automatically. The service maps the entity data to a DTO using manual property assignment. The HasSignature boolean tells the caller whether signature data was stored without returning the entire base64 string.

The Complete Service Implementation

Here is the full DeliveryConfirmationService.CreateAsync method with all steps combined:

csharp
1public class DeliveryConfirmationService : IDeliveryConfirmationService
2{
3 private readonly ParcelTrackingDbContext _db;
4
5 public DeliveryConfirmationService(ParcelTrackingDbContext db)
6 {
7 _db = db;
8 }
9
10 public async Task<DeliveryConfirmationDto> CreateAsync(
11 Guid parcelId,
12 CreateDeliveryConfirmationRequest request)
13 {
14 var parcel = await _db.Parcels
15 .Include(p => p.RecipientAddress)
16 .FirstOrDefaultAsync(p => p.Id == parcelId);
17
18 if (parcel is null)
19 {
20 throw new InvalidOperationException($"Parcel with ID '{parcelId}' not found.");
21 }
22
23 if (parcel.Status != ParcelStatus.InTransit &&
24 parcel.Status != ParcelStatus.OutForDelivery)
25 {
26 throw new InvalidOperationException(
27 $"Cannot confirm delivery. Parcel status is '{parcel.Status}'. " +
28 "Parcel must be in 'InTransit' or 'OutForDelivery' status.");
29 }
30
31 var existingConfirmation = await _db.DeliveryConfirmations
32 .AnyAsync(dc => dc.ParcelId == parcel.Id);
33
34 if (existingConfirmation)
35 {
36 throw new InvalidOperationException(
37 "Delivery confirmation already exists for this parcel.");
38 }
39
40 var confirmation = new DeliveryConfirmation
41 {
42 Id = Guid.NewGuid(),
43 ParcelId = parcel.Id,
44 ReceivedBy = request.ReceivedBy,
45 DeliveryLocation = request.DeliveryLocation,
46 SignatureImage = request.SignatureImage,
47 DeliveredAt = request.DeliveredAt,
48 CreatedAt = DateTime.UtcNow
49 };
50
51 var trackingEvent = new TrackingEvent
52 {
53 Id = Guid.NewGuid(),
54 ParcelId = parcel.Id,
55 EventType = EventType.Delivered,
56 Timestamp = request.DeliveredAt,
57 Description = $"Delivered to {request.ReceivedBy} at {request.DeliveryLocation}",
58 LocationCity = parcel.RecipientAddress?.City,
59 LocationState = parcel.RecipientAddress?.State,
60 LocationCountry = parcel.RecipientAddress?.CountryCode,
61 CreatedAt = DateTime.UtcNow
62 };
63
64 _db.DeliveryConfirmations.Add(confirmation);
65 _db.TrackingEvents.Add(trackingEvent);
66
67 parcel.Status = ParcelStatus.Delivered;
68 parcel.ActualDeliveryDate = request.DeliveredAt;
69 parcel.UpdatedAt = DateTime.UtcNow;
70
71 await _db.SaveChangesAsync();
72
73 return new DeliveryConfirmationDto
74 {
75 Id = confirmation.Id,
76 TrackingNumber = parcel.TrackingNumber,
77 ReceivedBy = confirmation.ReceivedBy,
78 DeliveryLocation = confirmation.DeliveryLocation,
79 HasSignature = confirmation.SignatureImage is not null,
80 DeliveredAt = confirmation.DeliveredAt,
81 CreatedAt = confirmation.CreatedAt
82 };
83 }
84
85 // GetDetailByParcelIdAsync will be implemented in the next lesson
86 public async Task<DeliveryConfirmationDetailDto?> GetDetailByParcelIdAsync(Guid parcelId)
87 {
88 throw new NotImplementedException("Will be implemented in the next lesson");
89 }
90}

Notice the .Include(p => p.RecipientAddress) in the CreateAsync query. This eager-loads the recipient address so we can use its city, state, and country in the tracking event description. The GetDetailByParcelIdAsync method will be fully implemented in the next lesson when we cover proof-of-delivery retrieval.

The Controller Action

With the service implemented, the controller action becomes thin and focused on HTTP concerns:

csharp
1[HttpPost("api/parcels/{trackingNumber}/delivery-confirmation")]
2public async Task<IActionResult> CreateDeliveryConfirmation(
3 string trackingNumber,
4 [FromBody] CreateDeliveryConfirmationRequest request)
5{
6 // Input validation
7 if (string.IsNullOrWhiteSpace(request.ReceivedBy))
8 return BadRequest(new { message = "'ReceivedBy' is required." });
9
10 if (string.IsNullOrWhiteSpace(request.DeliveryLocation))
11 return BadRequest(new { message = "'DeliveryLocation' is required." });
12
13 if (request.DeliveredAt > DateTime.UtcNow)
14 return BadRequest(new { message = "'DeliveredAt' cannot be in the future." });
15
16 if (request.SignatureImage is not null)
17 {
18 try
19 {
20 Convert.FromBase64String(request.SignatureImage);
21 }
22 catch (FormatException)
23 {
24 return BadRequest(new { message = "'SignatureImage' is not valid base64." });
25 }
26 }
27
28 // Lookup parcel by tracking number
29 var parcel = await _db.Parcels
30 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);
31
32 if (parcel is null)
33 {
34 return NotFound(new { message = $"Parcel '{trackingNumber}' not found." });
35 }
36
37 // Call service to handle business logic
38 try
39 {
40 var result = await _deliveryConfirmationService.CreateAsync(parcel.Id, request);
41 return CreatedAtAction(
42 nameof(GetDeliveryConfirmation),
43 new { trackingNumber },
44 result);
45 }
46 catch (InvalidOperationException ex) when (ex.Message.Contains("already exists"))
47 {
48 return Conflict(new { message = ex.Message, trackingNumber });
49 }
50 catch (InvalidOperationException ex)
51 {
52 return BadRequest(new { message = ex.Message });
53 }
54}

The controller action performs three clear responsibilities:

  1. Input validation: Validates the request DTO fields and returns 400 Bad Request for invalid data
  2. Tracking number lookup: Finds the parcel by tracking number and returns 404 if not found
  3. Exception translation: Calls the service and translates service exceptions to appropriate HTTP status codes (409 Conflict for duplicates, 400 Bad Request for other validation failures)

This separation keeps the service testable without HTTP dependencies and keeps the controller action focused on HTTP protocol concerns.

Registering the Service

Register the service in Program.cs before building the app:

csharp
1builder.Services.AddScoped<IDeliveryConfirmationService, DeliveryConfirmationService>();

The AddScoped lifetime ensures each HTTP request gets its own service instance, matching the DbContext lifetime. This prevents context-sharing issues across concurrent requests.

Testing the Endpoint

Test the endpoint with a tool like curl or the HTTP file in your IDE:

http
1POST /api/parcels/PKG-20250215-A1B2C3/delivery-confirmation
2Content-Type: application/json
3
4{
5 "receivedBy": "Jane Smith",
6 "deliveryLocation": "Front door",
7 "signatureImage": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
8 "deliveredAt": "2025-02-15T14:30:00Z"
9}

Expected response (201 Created):

json
1{
2 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
3 "trackingNumber": "PKG-20250215-A1B2C3",
4 "receivedBy": "Jane Smith",
5 "deliveryLocation": "Front door",
6 "hasSignature": true,
7 "deliveredAt": "2025-02-15T14:30:00Z",
8 "createdAt": "2025-02-15T14:35:12Z"
9}

Test the error cases too:

  • POST to a non-existent tracking number: expect 404
  • POST when parcel status is LabelCreated: expect 400
  • POST a second time for the same parcel: expect 409

Key Takeaways

  • The service layer encapsulates business logic and coordinates multi-entity operations (confirmation, tracking event, parcel update)
  • Services throw exceptions; endpoints translate them to HTTP status codes
  • The endpoint handler validates input, performs tracking number lookup, and delegates business logic to the service
  • Services work with domain IDs (Guid parcelId), not HTTP routing concerns (tracking numbers)
  • Manual DTO mapping gives explicit control over what data is returned
  • SaveChangesAsync provides transactional consistency across all three writes
  • Input validation catches bad data before business logic runs
  • 409 Conflict communicates duplicate confirmation attempts
  • Auto-creating a tracking event keeps the history complete
Building the Confirmation Endpoint - Anko Academy