Append-Only Event Logs for Tracking
In a parcel tracking system, the journey of a package from sender to recipient is captured as a series of events. Each scan at a facility, each departure, each delivery attempt becomes a permanent record in the parcel's history. This pattern --- an append-only event log --- is fundamental to how real carriers like FedEx and UPS operate.
Why Append-Only?
When a warehouse worker scans a parcel, that scan happened. You cannot un-scan a package. The event is a fact about something that occurred at a specific time and place. This is why tracking events are immutable and append-only:
- Events are never updated or deleted
- New events are always added to the end of the timeline
- The full history is always preserved
This differs from typical CRUD operations where you update a record in place. With an event log, the current state of a parcel is derived from its event history rather than stored as a single mutable row.
1Parcel PKG-001 Timeline:2──────────────────────────────────────────────────────────────3 10:00 AM PickedUp "Package picked up from sender"4 02:30 PM Departed "Left Chicago sorting facility"5 08:15 PM Arrived "Arrived at Indianapolis hub"6 06:00 AM Departed "Left Indianapolis hub"7 11:45 AM OutForDelivery "Out for delivery in Columbus"8 02:10 PM Delivered "Delivered - Left at front door"9──────────────────────────────────────────────────────────────
The TrackingEvent Model
Each tracking event captures what happened, when, and where:
csharp1public class TrackingEvent2{3 public Guid Id { get; set; }4 public Guid ParcelId { get; set; }5 public DateTime Timestamp { get; set; }6 public EventType 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; }1213 public Parcel Parcel { get; set; }14}
The EventType enum defines the vocabulary of events in our system:
csharp1public enum EventType2{3 PickedUp,4 Departed,5 Arrived,6 InTransit,7 OutForDelivery,8 DeliveryAttempt,9 Delivered,10 Exception,11 Returned12}
These event types mirror the stages a real parcel goes through. Each type carries semantic meaning that our system uses to update the parcel's current status.
Understanding Each Event Type
The event types form a vocabulary that describes a parcel's journey:
| Event Type | Meaning |
|---|---|
| PickedUp | Carrier has collected the parcel from the sender |
| Departed | Parcel has left a sorting facility or hub |
| Arrived | Parcel has arrived at a sorting facility or hub |
| InTransit | Parcel is moving between facilities |
| OutForDelivery | Parcel is on the delivery vehicle |
| DeliveryAttempt | Driver attempted delivery but could not complete it |
| Delivered | Parcel was successfully delivered to the recipient |
| Exception | Something went wrong (damage, customs hold, weather delay) |
| Returned | Parcel is being sent back to the original sender |
The DelayReason field is particularly relevant for Exception and DeliveryAttempt events. When a delivery fails because nobody was home, or a parcel is held at customs, the delay reason provides context that both internal staff and customers need to understand what happened.
Chronological Ordering as a Business Rule
Events must be added in chronological order. A parcel cannot depart a facility before it arrives. This is not just a data integrity concern --- it is a business rule that prevents nonsensical tracking histories.
The rule is straightforward: the timestamp of a new event must be equal to or later than the timestamp of the most recent event for that parcel. If someone tries to insert an event with an earlier timestamp, the API rejects it with a validation error.
1Most recent event: 2024-03-15 10:30:00 "Departed Chicago"2New event: 2024-03-15 08:00:00 "Arrived at Chicago" ← REJECTED3New event: 2024-03-15 14:00:00 "Arrived at Indy" ← ACCEPTED
This keeps the timeline consistent without requiring complex reordering logic.
Status Synchronization
When a new tracking event is added, the parcel's current status should reflect the latest event. Rather than requiring two separate API calls (one to add the event, one to update the parcel), the event creation endpoint handles both:
- Validate and persist the new tracking event
- Update the parcel's
Statusproperty based on the event type - Save both changes in a single transaction
This keeps the parcel status and event history always in sync. The parcel's status is effectively a projection of its latest event.
Note: In this topic, we implement a straightforward status synchronization using a direct mapping from event type to parcel status. This works well for the happy path. In the next topic (Status Lifecycle & Business Rules), we will introduce a formal state machine that validates whether a given transition is actually allowed --- for example, preventing a
Deliveredparcel from going back toInTransit. Think of this topic as building the mechanism, and the next topic as adding the guard rails.
csharp1// When a "Delivered" event is added:2parcel.Status = ParcelStatus.Delivered;34// When an "OutForDelivery" event is added:5parcel.Status = ParcelStatus.OutForDelivery;
Event Log vs. Event Sourcing
It is worth noting the distinction between an event log and full event sourcing. In event sourcing, the events are the single source of truth and the current state is always reconstructed by replaying events. In our system, we use a simpler approach:
- Events are stored as an append-only log (the history)
- The parcel entity maintains its own current status (the projection)
- Both are updated together in the same transaction
This gives us the benefits of a complete audit trail without the complexity of full event sourcing. The parcel table acts as a denormalized view that can be queried efficiently.
The Relationship in EF Core
The tracking events are modeled as a one-to-many relationship. A parcel has many events, and each event belongs to exactly one parcel:
csharp1public class Parcel2{3 public Guid Id { get; set; }4 public string TrackingNumber { get; set; }5 public ParcelStatus Status { get; set; }6 // ... other properties78 public ICollection<TrackingEvent> TrackingEvents { get; set; }9}
The ParcelId foreign key on TrackingEvent links each event back to its parcel. EF Core discovers this relationship by convention, but you can also configure it explicitly in the OnModelCreating method if needed.
Benefits of This Pattern
The append-only event log provides several advantages for a tracking API:
- Complete audit trail: Every state change is recorded with who, what, when, and where
- Debugging: When something goes wrong, the full history tells the story
- Customer transparency: Customers can see exactly where their parcel has been
- Analytics: Event data enables transit time analysis and bottleneck detection
- Immutability: No accidental overwrites --- once recorded, events are permanent
Summary
In this lesson, you learned:
- Tracking events follow an append-only pattern where events are never modified or deleted
- Each event captures a timestamp, event type, description, and location
- Chronological ordering is enforced as a business rule --- new events cannot predate existing ones
- The parcel's current status is automatically synchronized when a new event is added
- This approach provides a complete audit trail while keeping queries simple
Next, we will build the POST endpoint that adds tracking events and enforces these rules.