18 minlesson

Building the Tracking Events API

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:

csharp
1public class CreateTrackingEventRequest
2{
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 Exception or DeliveryAttempt events.

The Response DTO

After creating the event, we return the full event record including the server-generated ID:

csharp
1public class TrackingEventResponse
2{
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.

csharp
1[ApiController]
2[Route("api/parcels/{parcelId}/events")]
3public class TrackingEventsController : ControllerBase
4{
5 private readonly ParcelTrackingDbContext _context;
6
7 public TrackingEventsController(ParcelTrackingDbContext context)
8 {
9 _context = context;
10 }
11
12 [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:

csharp
1var parcel = await _context.Parcels
2 .FirstOrDefaultAsync(p => p.Id == parcelId);
3
4if (parcel is null)
5{
6 return NotFound(new ProblemDetails
7 {
8 Title = "Parcel not found",
9 Detail = $"No parcel exists with ID {parcelId}",
10 Status = 404
11 });
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:

csharp
1var latestEvent = await _context.TrackingEvents
2 .Where(e => e.ParcelId == parcelId)
3 .OrderByDescending(e => e.Timestamp)
4 .FirstOrDefaultAsync();
5
6if (latestEvent is not null && request.Timestamp < latestEvent.Timestamp)
7{
8 return BadRequest(new ProblemDetails
9 {
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 = 400
15 });
16}

Key points about this validation:

  • We only need the single most recent event, so OrderByDescending + FirstOrDefault is 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 Request with a ProblemDetails body 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:

csharp
1var trackingEvent = new TrackingEvent
2{
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.DelayReason
11};
12
13_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:

csharp
1parcel.Status = request.EventType switch
2{
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.Status
13};

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:

csharp
1await _context.SaveChangesAsync();
2
3var response = new TrackingEventResponse
4{
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.DelayReason
14};
15
16return 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:

csharp
1[HttpPost]
2public async Task<ActionResult<TrackingEventResponse>> Create(
3 Guid parcelId,
4 CreateTrackingEventRequest request)
5{
6 var parcel = await _context.Parcels
7 .FirstOrDefaultAsync(p => p.Id == parcelId);
8
9 if (parcel is null)
10 return NotFound(new ProblemDetails
11 {
12 Title = "Parcel not found",
13 Detail = $"No parcel exists with ID {parcelId}",
14 Status = 404
15 });
16
17 var latestEvent = await _context.TrackingEvents
18 .Where(e => e.ParcelId == parcelId)
19 .OrderByDescending(e => e.Timestamp)
20 .FirstOrDefaultAsync();
21
22 if (latestEvent is not null && request.Timestamp < latestEvent.Timestamp)
23 return BadRequest(new ProblemDetails
24 {
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 = 400
29 });
30
31 var trackingEvent = new TrackingEvent
32 {
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.DelayReason
41 };
42
43 _context.TrackingEvents.Add(trackingEvent);
44
45 parcel.Status = request.EventType switch
46 {
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.Status
57 };
58
59 await _context.SaveChangesAsync();
60
61 var response = new TrackingEventResponse
62 {
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.DelayReason
72 };
73
74 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:

csharp
1public class CreateTrackingEventRequest
2{
3 [Required]
4 public DateTime Timestamp { get; set; }
5
6 [Required]
7 public EventType EventType { get; set; }
8
9 [Required]
10 [MaxLength(500)]
11 public string Description { get; set; }
12
13 [Required]
14 [MaxLength(100)]
15 public string? LocationCity { get; set; }
16
17 [Required]
18 [MaxLength(100)]
19 public string? LocationState { get; set; }
20
21 [Required]
22 [MaxLength(100)]
23 public string? LocationCountry { get; set; }
24
25 [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:

bash
1curl -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):

json
1{
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": null
11}

And attempting an out-of-order event:

bash
1curl -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):

json
1{
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": 400
5}

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 Created with a properly formatted response

Next, we will build the GET endpoint for retrieving the full event history with date range filtering.

Building the Tracking Events API - Anko Academy