Building the Tracking Events API
Now that we understand the append-only event log pattern, let's build the POST endpoint that adds tracking events to a parcel. This endpoint must validate chronological ordering, persist the event, and automatically update the parcel's status.
Defining DTOs
Before implementing the service layer, we define the data contracts. The request DTO captures the event details from the client:
csharp1public class CreateTrackingEventRequest2{3 public DateTime Timestamp { get; set; }4 public EventType EventType { get; set; }5 public string Description { get; set; }6 public string? LocationCity { get; set; }7 public string? LocationState { get; set; }8 public string? LocationCountry { get; set; }9 public string? DelayReason { get; set; }10}
A few design decisions here:
- Timestamp is client-provided: The event time reflects when the physical scan happened, not when the API received it. A warehouse scanner might batch-upload events.
- Location is flat: City, state, and country are simple strings rather than a nested object. This keeps the API surface simple.
- DelayReason is optional: Only relevant for
ExceptionorDeliveryAttemptevents.
The response DTO returns the full event record including the server-generated ID:
csharp1public class TrackingEventDto2{3 public Guid Id { get; set; }4 public Guid ParcelId { get; set; }5 public DateTime Timestamp { get; set; }6 public string EventType { get; set; }7 public string Description { get; set; }8 public string? LocationCity { get; set; }9 public string? LocationState { get; set; }10 public string? LocationCountry { get; set; }11 public string? DelayReason { get; set; }12}
The EventType is returned as a string rather than an integer. This makes the API response human-readable without requiring the client to know the enum's numeric values.
The Service Interface
Now we define the service contract that encapsulates tracking event operations:
csharp1public interface ITrackingEventService2{3 Task<TrackingEventDto> CreateAsync(Guid parcelId, CreateTrackingEventRequest request);4 Task<List<TrackingEventDto>> GetByParcelIdAsync(Guid parcelId);5}
This interface abstracts the business logic for creating events and retrieving event history. The controller will depend on this interface rather than directly accessing the database context.
Implementing the Service
The service encapsulates all business logic for tracking events. Let's build it step by step.
Service Constructor
csharp1public class TrackingEventService : ITrackingEventService2{3 private readonly ParcelTrackingDbContext _context;45 public TrackingEventService(ParcelTrackingDbContext context)6 {7 _context = context;8 }9}
The service depends only on the database context. All domain logic lives here, not in the controller.
CreateAsync: Step 1 - Verify the Parcel Exists
The service method starts by loading the parcel entity:
csharp1public async Task<TrackingEventDto> CreateAsync(Guid parcelId, CreateTrackingEventRequest request)2{3 var parcel = await _context.Parcels4 .FirstOrDefaultAsync(p => p.Id == parcelId);56 if (parcel is null)7 {8 throw new KeyNotFoundException($"Parcel with ID {parcelId} not found.");9 }1011 // Additional steps follow...12}
We load the full parcel entity because we will need to update its status later. The service throws domain exceptions rather than returning ActionResult types --- the controller will handle translating these into appropriate HTTP responses.
CreateAsync: Step 2 - Enforce Chronological Ordering
The core business rule: a new event's timestamp must not be earlier than the most recent event:
csharp1var latestEvent = await _context.TrackingEvents2 .Where(e => e.ParcelId == parcelId)3 .OrderByDescending(e => e.Timestamp)4 .FirstOrDefaultAsync();56if (latestEvent is not null && request.Timestamp < latestEvent.Timestamp)7{8 throw new InvalidOperationException(9 $"Event timestamp {request.Timestamp:O} is earlier than the most recent event at {latestEvent.Timestamp:O}. " +10 "Events must be in chronological order.");11}
Key points about this validation:
- We only need the single most recent event, so
OrderByDescending+FirstOrDefaultis efficient - If no events exist yet (first event for this parcel), the check is skipped
- We throw an
InvalidOperationExceptionfor business rule violations - We use ISO 8601 format (
:O) for unambiguous timestamps
Why Not Just Sort on Insert?
You might wonder: why not accept events in any order and sort them chronologically when displaying? The answer is data integrity. If we allow out-of-order insertion:
- The parcel's status could be set incorrectly (e.g., overwriting "Delivered" with an earlier "InTransit")
- Concurrent inserts could create conflicting histories
- The "current status" concept becomes ambiguous
By enforcing order at write time, we guarantee the event log is always consistent.
CreateAsync: Step 3 - Create the Event Entity
With validation passed, we create the event entity and add it to the context:
csharp1var trackingEvent = new TrackingEvent2{3 ParcelId = parcelId,4 Timestamp = request.Timestamp,5 EventType = request.EventType,6 Description = request.Description,7 LocationCity = request.LocationCity,8 LocationState = request.LocationState,9 LocationCountry = request.LocationCountry,10 DelayReason = request.DelayReason11};1213_context.TrackingEvents.Add(trackingEvent);
Nothing is saved yet. We add the event to the Change Tracker, but SaveChanges happens after we also update the parcel.
CreateAsync: Step 4 - Sync the Parcel Status
The parcel's current status should reflect the latest event. We map the event type to the appropriate parcel status:
csharp1parcel.Status = request.EventType switch2{3 EventType.PickedUp => ParcelStatus.PickedUp,4 EventType.Departed => ParcelStatus.InTransit,5 EventType.Arrived => ParcelStatus.InTransit,6 EventType.InTransit => ParcelStatus.InTransit,7 EventType.OutForDelivery => ParcelStatus.OutForDelivery,8 EventType.DeliveryAttempt => ParcelStatus.OutForDelivery,9 EventType.Delivered => ParcelStatus.Delivered,10 EventType.Exception => ParcelStatus.Exception,11 EventType.Returned => ParcelStatus.Returned,12 _ => parcel.Status13};
Notice that the EventType and ParcelStatus enums are different. The event type is fine-grained (Departed, Arrived, InTransit are all different events), while the parcel status is coarse-grained (all three map to InTransit). This is intentional:
- Event types describe what happened at a specific moment
- Parcel status describes the current high-level state
The switch expression with pattern matching makes the mapping clear and exhaustive. The _ default case preserves the current status for any unexpected event type.
CreateAsync: Step 5 - Save and Return DTO
Both the new event and the updated parcel are saved in a single SaveChanges call. EF Core wraps this in a transaction automatically:
csharp1await _context.SaveChangesAsync();23return new TrackingEventDto4{5 Id = trackingEvent.Id,6 ParcelId = trackingEvent.ParcelId,7 Timestamp = trackingEvent.Timestamp,8 EventType = trackingEvent.EventType.ToString(),9 Description = trackingEvent.Description,10 LocationCity = trackingEvent.LocationCity,11 LocationState = trackingEvent.LocationState,12 LocationCountry = trackingEvent.LocationCountry,13 DelayReason = trackingEvent.DelayReason14};
The service returns a TrackingEventDto with the EventType converted to a string. This manual mapping ensures the service controls the exact shape of the returned data.
The Complete Service Method
Putting all the steps together:
csharp1public async Task<TrackingEventDto> CreateAsync(Guid parcelId, CreateTrackingEventRequest request)2{3 var parcel = await _context.Parcels4 .FirstOrDefaultAsync(p => p.Id == parcelId);56 if (parcel is null)7 throw new KeyNotFoundException($"Parcel with ID {parcelId} not found.");89 var latestEvent = await _context.TrackingEvents10 .Where(e => e.ParcelId == parcelId)11 .OrderByDescending(e => e.Timestamp)12 .FirstOrDefaultAsync();1314 if (latestEvent is not null && request.Timestamp < latestEvent.Timestamp)15 throw new InvalidOperationException(16 $"Event timestamp {request.Timestamp:O} is earlier than the most recent event at {latestEvent.Timestamp:O}. " +17 "Events must be in chronological order.");1819 var trackingEvent = new TrackingEvent20 {21 ParcelId = parcelId,22 Timestamp = request.Timestamp,23 EventType = request.EventType,24 Description = request.Description,25 LocationCity = request.LocationCity,26 LocationState = request.LocationState,27 LocationCountry = request.LocationCountry,28 DelayReason = request.DelayReason29 };3031 _context.TrackingEvents.Add(trackingEvent);3233 parcel.Status = request.EventType switch34 {35 EventType.PickedUp => ParcelStatus.PickedUp,36 EventType.Departed => ParcelStatus.InTransit,37 EventType.Arrived => ParcelStatus.InTransit,38 EventType.InTransit => ParcelStatus.InTransit,39 EventType.OutForDelivery => ParcelStatus.OutForDelivery,40 EventType.DeliveryAttempt => ParcelStatus.OutForDelivery,41 EventType.Delivered => ParcelStatus.Delivered,42 EventType.Exception => ParcelStatus.Exception,43 EventType.Returned => ParcelStatus.Returned,44 _ => parcel.Status45 };4647 await _context.SaveChangesAsync();4849 return new TrackingEventDto50 {51 Id = trackingEvent.Id,52 ParcelId = trackingEvent.ParcelId,53 Timestamp = trackingEvent.Timestamp,54 EventType = trackingEvent.EventType.ToString(),55 Description = trackingEvent.Description,56 LocationCity = trackingEvent.LocationCity,57 LocationState = trackingEvent.LocationState,58 LocationCountry = trackingEvent.LocationCountry,59 DelayReason = trackingEvent.DelayReason60 };61}
The Controller Implementation
With the service handling all business logic, the controller becomes a thin HTTP adapter:
csharp1[ApiController]2[Route("api/parcels/{parcelId}/events")]3public class TrackingEventsController : ControllerBase4{5 private readonly ITrackingEventService _trackingEventService;67 public TrackingEventsController(ITrackingEventService trackingEventService)8 {9 _trackingEventService = trackingEventService;10 }1112 [HttpPost]13 public async Task<ActionResult<TrackingEventDto>> Create(14 Guid parcelId,15 CreateTrackingEventRequest request)16 {17 try18 {19 var result = await _trackingEventService.CreateAsync(parcelId, request);20 return CreatedAtAction(nameof(GetEventHistory), new { parcelId }, result);21 }22 catch (KeyNotFoundException ex)23 {24 return NotFound(new ProblemDetails25 {26 Title = "Parcel not found",27 Detail = ex.Message,28 Status = 40429 });30 }31 catch (InvalidOperationException ex)32 {33 return BadRequest(new ProblemDetails34 {35 Title = "Invalid event timestamp",36 Detail = ex.Message,37 Status = 40038 });39 }40 }41}
The controller's responsibility is limited to:
- Routing and HTTP concerns (
POST /api/parcels/{parcelId}/events) - Translating service exceptions to HTTP status codes
- Returning appropriate
ActionResultresponses
All business logic lives in the service layer.
Registering the Service
In Program.cs, register the service with dependency injection:
csharp1builder.Services.AddScoped<ITrackingEventService, TrackingEventService>();
This enables constructor injection of ITrackingEventService in controllers and other services.
Input Validation with Data Annotations
We can add basic validation to the request DTO using data annotations:
csharp1public class CreateTrackingEventRequest2{3 [Required]4 public DateTime Timestamp { get; set; }56 [Required]7 public EventType EventType { get; set; }89 [Required]10 [MaxLength(500)]11 public string Description { get; set; }1213 [Required]14 [MaxLength(100)]15 public string? LocationCity { get; set; }1617 [Required]18 [MaxLength(100)]19 public string? LocationState { get; set; }2021 [Required]22 [MaxLength(100)]23 public string? LocationCountry { get; set; }2425 [MaxLength(500)]26 public string? DelayReason { get; set; }27}
With [ApiController] on the controller, ASP.NET Core automatically returns 400 Bad Request if model validation fails. This handles null or missing fields before our code runs.
Testing with curl
Here is an example of adding a tracking event:
bash1curl -X POST http://localhost:5000/api/parcels/f0e1d2c3-b4a5-6789-0abc-def123456789/events \2 -H "Content-Type: application/json" \3 -d '{4 "timestamp": "2024-03-15T10:30:00Z",5 "eventType": "PickedUp",6 "description": "Package picked up from sender",7 "locationCity": "Chicago",8 "locationState": "IL",9 "locationCountry": "US"10 }'
Expected response (201 Created with Location header):
json1{2 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",3 "parcelId": "f0e1d2c3-b4a5-6789-0abc-def123456789",4 "timestamp": "2024-03-15T10:30:00Z",5 "eventType": "PickedUp",6 "description": "Package picked up from sender",7 "locationCity": "Chicago",8 "locationState": "IL",9 "locationCountry": "US",10 "delayReason": null11}
The response uses TrackingEventDto which matches the request structure plus the generated ID.
And attempting an out-of-order event:
bash1curl -X POST http://localhost:5000/api/parcels/f0e1d2c3-b4a5-6789-0abc-def123456789/events \2 -H "Content-Type: application/json" \3 -d '{4 "timestamp": "2024-03-15T08:00:00Z",5 "eventType": "InTransit",6 "description": "Should be rejected",7 "locationCity": "Chicago",8 "locationState": "IL",9 "locationCountry": "US"10 }'
Expected response (400 Bad Request):
json1{2 "title": "Invalid event timestamp",3 "detail": "Event timestamp 2024-03-15T08:00:00.0000000Z is earlier than the most recent event at 2024-03-15T10:30:00.0000000Z.",4 "status": 4005}
Transactional Consistency
An important detail: SaveChangesAsync persists both the new tracking event and the updated parcel status in a single database transaction. If either operation fails, both are rolled back. This guarantees:
- You never have an event without the corresponding status update
- You never have a status update without the corresponding event
- The parcel's status always matches its most recent event
EF Core handles this automatically when you call SaveChanges once with multiple tracked changes. There is no need to manage transactions explicitly here.
Summary
In this section, you learned how to:
- Define a service interface (
ITrackingEventService) that abstracts tracking event operations - Implement business logic in a service class that throws domain exceptions
- Build a thin controller that delegates to the service and translates exceptions to HTTP responses
- Validate that the parent entity exists before creating a child record
- Enforce chronological ordering by comparing against the most recent event
- Map event types to parcel statuses using a switch expression
- Save both the event and the status update atomically with
SaveChanges - Use manual mapping to return DTOs from the service layer
- Register services with dependency injection in
Program.cs
Next, we will extend the service to support retrieving the full event history with date range filtering.