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 DTOs

Before implementing the service layer, we define the data contracts. The request DTO captures the event details from the client:

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 returns the full event record including the server-generated ID:

csharp
1public class TrackingEventDto
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.

The Service Interface

Now we define the service contract that encapsulates tracking event operations:

csharp
1public interface ITrackingEventService
2{
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

csharp
1public class TrackingEventService : ITrackingEventService
2{
3 private readonly ParcelTrackingDbContext _context;
4
5 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:

csharp
1public async Task<TrackingEventDto> CreateAsync(Guid parcelId, CreateTrackingEventRequest request)
2{
3 var parcel = await _context.Parcels
4 .FirstOrDefaultAsync(p => p.Id == parcelId);
5
6 if (parcel is null)
7 {
8 throw new KeyNotFoundException($"Parcel with ID {parcelId} not found.");
9 }
10
11 // 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:

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 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 + FirstOrDefault is efficient
  • If no events exist yet (first event for this parcel), the check is skipped
  • We throw an InvalidOperationException for 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:

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.

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:

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.

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:

csharp
1await _context.SaveChangesAsync();
2
3return new TrackingEventDto
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};

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:

csharp
1public async Task<TrackingEventDto> CreateAsync(Guid parcelId, CreateTrackingEventRequest request)
2{
3 var parcel = await _context.Parcels
4 .FirstOrDefaultAsync(p => p.Id == parcelId);
5
6 if (parcel is null)
7 throw new KeyNotFoundException($"Parcel with ID {parcelId} not found.");
8
9 var latestEvent = await _context.TrackingEvents
10 .Where(e => e.ParcelId == parcelId)
11 .OrderByDescending(e => e.Timestamp)
12 .FirstOrDefaultAsync();
13
14 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.");
18
19 var trackingEvent = new TrackingEvent
20 {
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.DelayReason
29 };
30
31 _context.TrackingEvents.Add(trackingEvent);
32
33 parcel.Status = request.EventType switch
34 {
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.Status
45 };
46
47 await _context.SaveChangesAsync();
48
49 return new TrackingEventDto
50 {
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.DelayReason
60 };
61}

The Controller Implementation

With the service handling all business logic, the controller becomes a thin HTTP adapter:

csharp
1[ApiController]
2[Route("api/parcels/{parcelId}/events")]
3public class TrackingEventsController : ControllerBase
4{
5 private readonly ITrackingEventService _trackingEventService;
6
7 public TrackingEventsController(ITrackingEventService trackingEventService)
8 {
9 _trackingEventService = trackingEventService;
10 }
11
12 [HttpPost]
13 public async Task<ActionResult<TrackingEventDto>> Create(
14 Guid parcelId,
15 CreateTrackingEventRequest request)
16 {
17 try
18 {
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 ProblemDetails
25 {
26 Title = "Parcel not found",
27 Detail = ex.Message,
28 Status = 404
29 });
30 }
31 catch (InvalidOperationException ex)
32 {
33 return BadRequest(new ProblemDetails
34 {
35 Title = "Invalid event timestamp",
36 Detail = ex.Message,
37 Status = 400
38 });
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 ActionResult responses

All business logic lives in the service layer.

Registering the Service

In Program.cs, register the service with dependency injection:

csharp
1builder.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:

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 with Location header):

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}

The response uses TrackingEventDto which matches the request structure plus the generated ID.

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:

  • 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.

Building the Tracking Events API - Anko Academy