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:
csharp1public class CreateDeliveryConfirmationRequest2{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:
csharp1public class DeliveryConfirmationDto2{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:
csharp1public interface IDeliveryConfirmationService2{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:
csharp1public class DeliveryConfirmationService : IDeliveryConfirmationService2{3 private readonly ParcelTrackingDbContext _db;45 public DeliveryConfirmationService(ParcelTrackingDbContext db)6 {7 _db = db;8 }910 public async Task<DeliveryConfirmationDto> CreateAsync(11 Guid parcelId,12 CreateDeliveryConfirmationRequest request)13 {14 var parcel = await _db.Parcels15 .Include(p => p.RecipientAddress)16 .FirstOrDefaultAsync(p => p.Id == parcelId);1718 if (parcel is null)19 {20 throw new InvalidOperationException($"Parcel with ID '{parcelId}' not found.");21 }2223 // 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:
csharp1if (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:
csharp1var existingConfirmation = await _db.DeliveryConfirmations2 .AnyAsync(dc => dc.ParcelId == parcel.Id);34if (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:
csharp1var confirmation = new DeliveryConfirmation2{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.UtcNow10};1112_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:
csharp1var trackingEvent = new TrackingEvent2{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.UtcNow12};1314_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:
csharp1parcel.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:
csharp1await _db.SaveChangesAsync();23return new DeliveryConfirmationDto4{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.CreatedAt12};
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:
csharp1public class DeliveryConfirmationService : IDeliveryConfirmationService2{3 private readonly ParcelTrackingDbContext _db;45 public DeliveryConfirmationService(ParcelTrackingDbContext db)6 {7 _db = db;8 }910 public async Task<DeliveryConfirmationDto> CreateAsync(11 Guid parcelId,12 CreateDeliveryConfirmationRequest request)13 {14 var parcel = await _db.Parcels15 .Include(p => p.RecipientAddress)16 .FirstOrDefaultAsync(p => p.Id == parcelId);1718 if (parcel is null)19 {20 throw new InvalidOperationException($"Parcel with ID '{parcelId}' not found.");21 }2223 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 }3031 var existingConfirmation = await _db.DeliveryConfirmations32 .AnyAsync(dc => dc.ParcelId == parcel.Id);3334 if (existingConfirmation)35 {36 throw new InvalidOperationException(37 "Delivery confirmation already exists for this parcel.");38 }3940 var confirmation = new DeliveryConfirmation41 {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.UtcNow49 };5051 var trackingEvent = new TrackingEvent52 {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.UtcNow62 };6364 _db.DeliveryConfirmations.Add(confirmation);65 _db.TrackingEvents.Add(trackingEvent);6667 parcel.Status = ParcelStatus.Delivered;68 parcel.ActualDeliveryDate = request.DeliveredAt;69 parcel.UpdatedAt = DateTime.UtcNow;7071 await _db.SaveChangesAsync();7273 return new DeliveryConfirmationDto74 {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.CreatedAt82 };83 }8485 // GetDetailByParcelIdAsync will be implemented in the next lesson86 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:
csharp1[HttpPost("api/parcels/{trackingNumber}/delivery-confirmation")]2public async Task<IActionResult> CreateDeliveryConfirmation(3 string trackingNumber,4 [FromBody] CreateDeliveryConfirmationRequest request)5{6 // Input validation7 if (string.IsNullOrWhiteSpace(request.ReceivedBy))8 return BadRequest(new { message = "'ReceivedBy' is required." });910 if (string.IsNullOrWhiteSpace(request.DeliveryLocation))11 return BadRequest(new { message = "'DeliveryLocation' is required." });1213 if (request.DeliveredAt > DateTime.UtcNow)14 return BadRequest(new { message = "'DeliveredAt' cannot be in the future." });1516 if (request.SignatureImage is not null)17 {18 try19 {20 Convert.FromBase64String(request.SignatureImage);21 }22 catch (FormatException)23 {24 return BadRequest(new { message = "'SignatureImage' is not valid base64." });25 }26 }2728 // Lookup parcel by tracking number29 var parcel = await _db.Parcels30 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);3132 if (parcel is null)33 {34 return NotFound(new { message = $"Parcel '{trackingNumber}' not found." });35 }3637 // Call service to handle business logic38 try39 {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:
- Input validation: Validates the request DTO fields and returns 400 Bad Request for invalid data
- Tracking number lookup: Finds the parcel by tracking number and returns 404 if not found
- 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:
csharp1builder.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:
http1POST /api/parcels/PKG-20250215-A1B2C3/delivery-confirmation2Content-Type: application/json34{5 "receivedBy": "Jane Smith",6 "deliveryLocation": "Front door",7 "signatureImage": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",8 "deliveredAt": "2025-02-15T14:30:00Z"9}
Expected response (201 Created):
json1{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
SaveChangesAsyncprovides transactional consistency across all three writes- Input validation catches bad data before business logic runs
409 Conflictcommunicates duplicate confirmation attempts- Auto-creating a tracking event keeps the history complete