Event Sourcing Pattern

Event Sourcing Pattern

"Event Sourcing ensures that all changes to application state are stored as a sequence of events." — Martin Fowler, martinfowler.com/eaaDev/EventSourcing.html

Intent

Event Sourcing stores each state change as an immutable event appended to a per-aggregate stream. The event store is the system of record — current state is derived by replaying events, not stored directly. There is no "UPDATE" — only "APPEND".

Event Sourcing is independent of CQRS. ES is a storage pattern: how state is persisted. CQRS is a service separation pattern: how reads and writes are handled by different models. They compose naturally — the ES write side emits events that CQRS projections consume — but neither requires the other. A system can use ES without CQRS (replaying to rebuild domain state only) or CQRS without ES (using a relational database on the write side).

Aggregate state is rebuilt by replaying the event stream for that aggregate ID from the beginning (or from the latest snapshot). Each event represents a discrete state transition that already occurred — events are facts, never intentions.

When NOT to Use

  • Simple CRUD where an audit log table suffices — append-only log is simpler and has lower operational overhead
  • Systems where event schema will change frequently without a versioning plan in place — upcasters must precede every schema change
  • Teams unfamiliar with eventual consistency — ES read models are eventually consistent; this must be architecturally acceptable
  • High-frequency write aggregates where append + replay overhead exceeds direct state mutation cost — measure before adopting
  • Greenfield projects with no audit, temporal query, or replay requirement — default to simpler persistence until the need is proven

When to Use

  • Full audit trail is a business requirement — finance, healthcare, compliance domains where "what changed and when" is non-negotiable
  • Temporal queries — "what was the state of this aggregate at time T?" is answered by replaying to that point
  • Event replay enables debugging production issues by reproducing aggregate state at any historical point
  • Domain events are already the primary communication pattern — ES makes those events the persistence mechanism
  • Complex domain with compensating transactions — ES provides the event log that saga rollback requires

How It Works

Each state change is an immutable event appended to a per-aggregate stream (e.g., order-ord-1). Streams are identified by aggregate type + ID. The event store is append-only — events are never updated or deleted.

Rehydration: Load aggregate state by reading the full event stream and applying each event to an initially empty aggregate. The aggregate's event handler methods (@EventSourcingHandler in Axon, custom apply() in plain TS) mutate in-memory state.

Optimistic concurrency: Append operations include an expected revision number. If the stream has advanced (concurrent write), the append fails — the caller retries with fresh state.

Event structure: Each event carries a type, timestamp, aggregate ID, and a payload of the changed data. Events are named in past tense: OrderPlaced, OrderConfirmed, OrderCancelled.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Agg as Order Aggregate
    participant ES as Event Store
    participant Proj as Projection Handler
    participant RM as Read Model DB

    Note over Client,RM: Command Path (write)
    Client->>Agg: CreateOrderCommand
    Agg->>Agg: Validate invariants
    Agg->>ES: Append OrderCreatedEvent<br/>(expectedRevision for optimistic concurrency)
    ES-->>Agg: Revision confirmed

    Client->>Agg: ConfirmOrderCommand
    Agg->>ES: Read stream (rehydrate from events)
    ES-->>Agg: [OrderCreatedEvent]
    Agg->>Agg: Apply events to rebuild state
    Agg->>Agg: Validate confirm invariants
    Agg->>ES: Append OrderConfirmedEvent

    Note over Client,RM: Projection Path (read model rebuild)
    ES->>Proj: Subscribe / catchup from position 0
    Proj->>RM: Upsert OrderView (status: CREATED)
    ES->>Proj: OrderConfirmedEvent
    Proj->>RM: Update OrderView (status: CONFIRMED)

    Note over ES,RM: Read model is derived and rebuildable:<br/>truncate + replay from position 0

Snapshot Strategy

When to snapshot: Replay latency exceeds acceptable threshold — rule of thumb: >100ms for hot aggregates, or stream length exceeds ~200 events. Test first; do not add snapshots prematurely — they add operational complexity.

Trigger strategies:

StrategyTriggerBest ForRisk
Every N eventseventCount % N == 0General purpose hot aggregatesOverhead if N too small
Business event triggerOrderShipped, ShiftEndedDomain-aligned lifecycle breaksLess predictable timing
ScheduledDaily/hourly cronLow-traffic aggregatesPerformance spike at interval
Async subscriptionBackground subscriberZero write-path impactLag between event and snapshot

Recommended N: 50–200 events depending on event size and acceptable replay latency.

Load pattern:

  1. Read latest snapshot from snapshot store (if exists)
  2. Read events from stream starting at snapshot.revision + 1
  3. Apply snapshot state as initial aggregate state
  4. Replay subsequent events over snapshot state
  5. Use resulting state for command handling

Storage: Separate stream or external store (Redis, DB table) preferred over embedding snapshots in the event stream — simplifies schema migration and keeps event stream clean.

Warning: Snapshot schema must be versioned alongside event schema. A snapshot serialised against the old domain model will fail to deserialise after a domain object rename or field addition.

Schema Versioning

Problem: Event bytes are immutable and stored forever. Domain models evolve. Old event schemas break deserialisation when the consuming aggregate or projection code changes.

Solution — Upcasting: Register pure transformation functions (oldEventJson) => newEventJson at the serialisation layer. Upcasters are applied at read time; event bytes in the store are never mutated.

Upcaster chain: Multiple version transitions are supported by chaining upcasters. Each upcaster handles exactly one version transition: v1 → v2, v2 → v3. The chain is applied in order before the event is dispatched to the aggregate handler or projection.

// Schema Versioning — TypeScript upcaster chain concept
// Applied at read time; event bytes in store are never mutated
type EventUpcaster = (raw: Record<string, unknown>) => Record<string, unknown>;
 
const upcasters: Record<string, EventUpcaster> = {
  'OrderPlaced-v1': (e) => ({ ...e, currency: 'USD' }),  // v1 lacked currency field
};
 
function applyUpcasters(eventType: string, raw: unknown): unknown {
  const upcaster = upcasters[eventType];
  return upcaster ? upcaster(raw as Record<string, unknown>) : raw;
}
// Call applyUpcasters before deserialising event to domain type

Axon Framework: Provides @Upcaster annotation on methods in an IntermediateEventRepresentation-transforming class; chain is registered in the event serialiser configuration.

Best practice: Every breaking event schema change requires a corresponding upcaster deployed BEFORE the code change. Test upcasters with historical event samples from production or staging.

TypeScript Example

// Event Sourcing — TypeScript (EventStoreDB @eventstore/db-client)
// Source: kurrent.io/blog/nodejs-v1-release (verified); npm @eventstore/db-client
import { EventStoreDBClient, jsonEvent } from '@eventstore/db-client';
 
const client = EventStoreDBClient.connectionString('esdb://localhost:2113?tls=false');
 
// Append event to aggregate stream (optimistic concurrency via expectedRevision)
const event = jsonEvent({
  type: 'OrderPlaced',
  data: { orderId: 'ord-1', total: 99.99 },
});
await client.appendToStream('order-ord-1', event);
 
// Rehydrate aggregate: replay all events from stream start
const events = client.readStream('order-ord-1', { fromRevision: 'start' });
let state: Record<string, unknown> = {};
for await (const { event: e } of events) {
  if (e?.type === 'OrderPlaced') state = { ...state, orderId: e.data.orderId };
  // apply each event type to rebuild current state
}
// state now reflects all events — no separate state table needed

Java Example

// Event Sourcing — Java (Axon Framework — aggregate sourced from events)
// Source: axoniq.io/products/axon-framework; Java Code Geeks 2025 Axon + Spring Boot guide
@Aggregate
public class OrderAggregate {
    @AggregateIdentifier private String orderId;
    private String status;
 
    @CommandHandler
    public OrderAggregate(CreateOrderCommand cmd) {
        // validate, then emit — state is NEVER mutated in command handler
        apply(new OrderCreatedEvent(cmd.getOrderId(), "PENDING"));
    }
 
    @CommandHandler
    public void handle(ConfirmOrderCommand cmd) {
        apply(new OrderConfirmedEvent(this.orderId));
    }
 
    @EventSourcingHandler          // state is ONLY mutated here
    public void on(OrderCreatedEvent e) {
        this.orderId = e.getOrderId();
        this.status = e.getStatus();
    }
 
    @EventSourcingHandler
    public void on(OrderConfirmedEvent e) { this.status = "CONFIRMED"; }
    // Axon replays @EventSourcingHandler methods to rehydrate — snapshot integration is built-in
}

Lineage Backward

  • Domain-Events — ES persists domain events as the source of truth; domain events are the primitive ES stores in its append-only log
  • Aggregate — each aggregate has its own event stream identified by aggregate ID; stream-per-aggregate is the standard ES partitioning strategy

Lineage Forward

  • Projections — read models are built by subscribing to and replaying the event store; projections ARE the consumer of the ES event stream
  • CQRS-Pattern — ES write side emits to the event store; CQRS projections consume that stream (independent but natural composition)
PatternRelationship
Domain-EventsUpstream primitive — domain events are what ES stores
AggregateEach aggregate has its own event stream (stream-per-aggregate)
CQRS-PatternIndependent pattern that composes well — ES is the write side; CQRS projections are the read side
ProjectionsRead model derived from replaying the event store

Sources