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:
| Strategy | Trigger | Best For | Risk |
|---|---|---|---|
| Every N events | eventCount % N == 0 | General purpose hot aggregates | Overhead if N too small |
| Business event trigger | OrderShipped, ShiftEnded | Domain-aligned lifecycle breaks | Less predictable timing |
| Scheduled | Daily/hourly cron | Low-traffic aggregates | Performance spike at interval |
| Async subscription | Background subscriber | Zero write-path impact | Lag between event and snapshot |
Recommended N: 50–200 events depending on event size and acceptable replay latency.
Load pattern:
- Read latest snapshot from snapshot store (if exists)
- Read events from stream starting at
snapshot.revision + 1 - Apply snapshot state as initial aggregate state
- Replay subsequent events over snapshot state
- 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 typeAxon 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 neededJava 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)
Related Concepts
| Pattern | Relationship |
|---|---|
| Domain-Events | Upstream primitive — domain events are what ES stores |
| Aggregate | Each aggregate has its own event stream (stream-per-aggregate) |
| CQRS-Pattern | Independent pattern that composes well — ES is the write side; CQRS projections are the read side |
| Projections | Read model derived from replaying the event store |
Sources
- Martin Fowler, Event Sourcing — https://martinfowler.com/eaaDev/EventSourcing.html
- KurrentDB / EventStoreDB Node.js client — https://www.kurrent.io/blog/nodejs-v1-release
- EventStoreDB docs — https://docs.kurrent.io (formerly developers.eventstore.com)
- Axon Framework — https://www.axoniq.io/products/axon-framework
- kurrent.io, Snapshots in Event Sourcing — https://www.kurrent.io/blog/snapshots-in-event-sourcing