10 minlesson

Append-Only Event Logs for Tracking

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:

csharp
1public class TrackingEvent
2{
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; }
12
13 public Parcel Parcel { get; set; }
14}

The EventType enum defines the vocabulary of events in our system:

csharp
1public enum EventType
2{
3 PickedUp,
4 Departed,
5 Arrived,
6 InTransit,
7 OutForDelivery,
8 DeliveryAttempt,
9 Delivered,
10 Exception,
11 Returned
12}

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 TypeMeaning
PickedUpCarrier has collected the parcel from the sender
DepartedParcel has left a sorting facility or hub
ArrivedParcel has arrived at a sorting facility or hub
InTransitParcel is moving between facilities
OutForDeliveryParcel is on the delivery vehicle
DeliveryAttemptDriver attempted delivery but could not complete it
DeliveredParcel was successfully delivered to the recipient
ExceptionSomething went wrong (damage, customs hold, weather delay)
ReturnedParcel 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" ← REJECTED
3New 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:

  1. Validate and persist the new tracking event
  2. Update the parcel's Status property based on the event type
  3. 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 Delivered parcel from going back to InTransit. Think of this topic as building the mechanism, and the next topic as adding the guard rails.

csharp
1// When a "Delivered" event is added:
2parcel.Status = ParcelStatus.Delivered;
3
4// 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:

csharp
1public class Parcel
2{
3 public Guid Id { get; set; }
4 public string TrackingNumber { get; set; }
5 public ParcelStatus Status { get; set; }
6 // ... other properties
7
8 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.