Event Sourcing

Event Sourcing (ES) is a persistence pattern that treats the history of an entity as the primary source of truth. Unlike traditional CRUD (Create, Read, Update, Delete) where the database stores a snapshot of the current state, ES stores an immutable, append-only log of every state change (events).

The State Reconstruction Equation

Current state Sat timetis the deterministic result of folding an initial stateS_0over a chronologically ordered sequence of eventsE:

S_t = S_0 \oplus e_1 \oplus e_2 \oplus \dots \oplus e_n

In implementation terms, this means the state is a projection of history. This approach provides a perfect audit trail, the ability to time-travel (reconstruct state at any point in history), and simplified concurrency through append-only semantics.

Aggregate Snapshotting

As the event stream grows, replayingNevents becomes a performance bottleneck (O(N) reconstruction). Snapshotting introduces a performance shortcut.

Snapshot Strategy

  1. Frequency: Typically every 50-100 events, or based on a calculated replay-time budget.
  2. Versioning: Snapshots must include the sequence_number of the last event processed.
  3. Storage: Snapshots are often stored in a key-value store or a specialized table in the event store, separate from the primary log.

Reconstruction Logic with Snapshots:

public OrderAggregate load(String aggregateId) {
    Snapshot<OrderState> snap = snapshotStore.load(aggregateId);
    OrderAggregate aggregate = new OrderAggregate(snap.getState());
    
    // Resume replay from the event immediately following the snapshot
    List<Event> events = eventStore.readStream(aggregateId, snap.getVersion() + 1);
    for (Event e : events) {
        aggregate.apply(e);
    }
    return aggregate;
}

Read-Model Projections

Projections (Materialized Views) transform the raw event stream into a format optimized for specific query patterns. This is the "Query" side of CQRS.

Idempotency and At-Least-Once Delivery

Most event brokers (Kafka, RabbitMQ) guarantee at-least-once delivery. Projection handlers must be idempotent to prevent data corruption during retries.

Implementation Patterns for Idempotent Projections:

-- PostgreSQL Atomic Projection Update
BEGIN;
  UPDATE user_account_summary 
  SET balance = balance + :amount, last_event_id = :event_id
  WHERE user_id = :user_id AND last_event_id < :event_id;
COMMIT;

Projection Replays

When projection logic changes (e.g., adding a new field or fixing a calculation bug), the read model is "replayed":

  1. Shadow Table: Create a new projection table.
  2. Pointer Reset: Point a new consumer group to the beginning of the event stream.
  3. Hydration: Process events into the shadow table.
  4. Cutover: Atomically swap the query traffic to the new table once the consumer catch-up latency is near zero.

Event Upcasting: Handling Schema Evolution

Events are immutable, but code is not. If an event schema changes (e.g., OrderPlaced gains a currency field), you cannot rewrite the history.

Upcasting Strategies:

Upcaster Example:

public class OrderPlacedUpcaster implements Upcaster {
    public JsonNode upcast(JsonNode eventJson) {
        if (!eventJson.has("currency")) {
            ((ObjectNode) eventJson).put("currency", "USD"); // Default for legacy events
        }
        return eventJson;
    }
}

Determinism and Side Effects

Crucial Rule: The apply(Event e) method in an Aggregate must be a pure function.

Failure to follow this rule makes state reconstruction non-deterministic, rendering the event log useless for recovery.

Further Reading