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 the Request DTO
The client sends a request with the event details. We do not let the client set the event ID --- that is generated by the database. The parcel ID comes from the route:
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
After creating the event, we return the full event record including the server-generated ID:
csharp1public class TrackingEventResponse2{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.
Setting Up the Endpoint
The endpoint lives under the parcel resource, since events belong to a parcel:
POST /api/parcels/{parcelId}/events
This URL structure expresses the relationship clearly: you are adding an event to a specific parcel.
csharp1[ApiController]2[Route("api/parcels/{parcelId}/events")]3public class TrackingEventsController : ControllerBase4{5 private readonly ParcelTrackingDbContext _context;67 public TrackingEventsController(ParcelTrackingDbContext context)8 {9 _context = context;10 }1112 [HttpPost]13 public async Task<ActionResult<TrackingEventResponse>> Create(14 Guid parcelId,15 CreateTrackingEventRequest request)16 {17 // Implementation follows...18 }19}
The parcelId parameter is bound from the route. ASP.NET Core matches the {parcelId} segment to the method parameter automatically.
Step 1: Verify the Parcel Exists
Before adding an event, we must ensure the parcel exists. If it does not, we return a 404:
csharp1var parcel = await _context.Parcels2 .FirstOrDefaultAsync(p => p.Id == parcelId);34if (parcel is null)5{6 return NotFound(new ProblemDetails7 {8 Title = "Parcel not found",9 Detail = $"No parcel exists with ID {parcelId}",10 Status = 40411 });12}
We load the full parcel entity because we will need to update its status later. Using Find or FirstOrDefault here is appropriate since we need the entity, not just an existence check.
Step 2: Enforce Chronological Ordering
The core business rule: a new event's timestamp must not be earlier than the most recent event. We query for the latest event on this parcel:
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 return BadRequest(new ProblemDetails9 {10 Title = "Invalid event timestamp",11 Detail = $"Event timestamp {request.Timestamp:O} is earlier than the most "12 + $"recent event at {latestEvent.Timestamp:O}. "13 + "Events must be in chronological order.",14 Status = 40015 });16}
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 use ISO 8601 format (
:O) in the error message for unambiguous timestamps - We return a
400 Bad Requestwith aProblemDetailsbody explaining what went wrong
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.
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.
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.
Step 5: Save and Return
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();23var response = new TrackingEventResponse4{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};1516return CreatedAtAction(17 nameof(GetEventHistory),18 new { parcelId },19 response);
We return 201 Created with a Location header pointing to the event history endpoint. The EventType is converted to a string with .ToString() so the JSON response contains "Delivered" instead of 6.
The Complete Endpoint
Putting all the steps together:
csharp1[HttpPost]2public async Task<ActionResult<TrackingEventResponse>> Create(3 Guid parcelId,4 CreateTrackingEventRequest request)5{6 var parcel = await _context.Parcels7 .FirstOrDefaultAsync(p => p.Id == parcelId);89 if (parcel is null)10 return NotFound(new ProblemDetails11 {12 Title = "Parcel not found",13 Detail = $"No parcel exists with ID {parcelId}",14 Status = 40415 });1617 var latestEvent = await _context.TrackingEvents18 .Where(e => e.ParcelId == parcelId)19 .OrderByDescending(e => e.Timestamp)20 .FirstOrDefaultAsync();2122 if (latestEvent is not null && request.Timestamp < latestEvent.Timestamp)23 return BadRequest(new ProblemDetails24 {25 Title = "Invalid event timestamp",26 Detail = $"Event timestamp {request.Timestamp:O} is earlier than "27 + $"the most recent event at {latestEvent.Timestamp:O}.",28 Status = 40029 });3031 var trackingEvent = new TrackingEvent32 {33 ParcelId = parcelId,34 Timestamp = request.Timestamp,35 EventType = request.EventType,36 Description = request.Description,37 LocationCity = request.LocationCity,38 LocationState = request.LocationState,39 LocationCountry = request.LocationCountry,40 DelayReason = request.DelayReason41 };4243 _context.TrackingEvents.Add(trackingEvent);4445 parcel.Status = request.EventType switch46 {47 EventType.PickedUp => ParcelStatus.PickedUp,48 EventType.Departed => ParcelStatus.InTransit,49 EventType.Arrived => ParcelStatus.InTransit,50 EventType.InTransit => ParcelStatus.InTransit,51 EventType.OutForDelivery => ParcelStatus.OutForDelivery,52 EventType.DeliveryAttempt => ParcelStatus.OutForDelivery,53 EventType.Delivered => ParcelStatus.Delivered,54 EventType.Exception => ParcelStatus.Exception,55 EventType.Returned => ParcelStatus.Returned,56 _ => parcel.Status57 };5859 await _context.SaveChangesAsync();6061 var response = new TrackingEventResponse62 {63 Id = trackingEvent.Id,64 ParcelId = trackingEvent.ParcelId,65 Timestamp = trackingEvent.Timestamp,66 EventType = trackingEvent.EventType.ToString(),67 Description = trackingEvent.Description,68 LocationCity = trackingEvent.LocationCity,69 LocationState = trackingEvent.LocationState,70 LocationCountry = trackingEvent.LocationCountry,71 DelayReason = trackingEvent.DelayReason72 };7374 return CreatedAtAction(75 nameof(GetEventHistory),76 new { parcelId },77 response);78}
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):
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}
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:
- Design a POST endpoint nested under a parent resource (
/api/parcels/{parcelId}/events) - 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 - Return
201 Createdwith a properly formatted response
Next, we will build the GET endpoint for retrieving the full event history with date range filtering.