Event Sourcing
Event Sourcing: store every change as an event; rebuild state by replay. The pattern behind audit-heavy + temporal systems.
Design patterns — Event Sourcing
EXAMPLE
// ===== The idea =====
// Instead of storing CURRENT STATE, store every EVENT that changed it.
// Reconstruct state at any point by replaying events.
//
// Aggregate: a consistency boundary (Account, Order, Cart)
// Event: a fact about something that happened (OrderPlaced, OrderShipped)
// Stream: ordered sequence of events for one aggregate
// ===== A tiny example =====
type Event =
| { kind: 'AccountOpened'; id: string; owner: string; at: string }
| { kind: 'MoneyDeposited'; id: string; amount: number; at: string }
| { kind: 'MoneyWithdrawn'; id: string; amount: number; at: string }
| { kind: 'AccountClosed'; id: string; at: string };
interface Account {
id: string;
owner?: string;
balance: number;
closed: boolean;
}
function apply(state: Account, event: Event): Account {
switch (event.kind) {
case 'AccountOpened':
return { ...state, id: event.id, owner: event.owner, balance: 0, closed: false };
case 'MoneyDeposited':
return { ...state, balance: state.balance + event.amount };
case 'MoneyWithdrawn':
return { ...state, balance: state.balance - event.amount };
case 'AccountClosed':
return { ...state, closed: true };
}
}
function replay(events: Event[]): Account {
return events.reduce(apply, { id: '', balance: 0, closed: false });
}
// ===== Command -> Event(s) =====
function handle(state: Account, command: any): Event[] {
switch (command.kind) {
case 'OpenAccount':
if (state.id) throw new Error('exists');
return [{ kind: 'AccountOpened', id: command.id, owner: command.owner, at: now() }];
case 'Deposit':
if (state.closed) throw new Error('closed');
if (command.amount <= 0) throw new Error('positive only');
return [{ kind: 'MoneyDeposited', id: state.id, amount: command.amount, at: now() }];
case 'Withdraw':
if (state.closed) throw new Error('closed');
if (state.balance < command.amount) throw new Error('insufficient');
return [{ kind: 'MoneyWithdrawn', id: state.id, amount: command.amount, at: now() }];
}
return [];
}
// ===== Persistence (event store) =====
interface EventStore {
loadStream(id: string): Promise<Event[]>;
appendStream(id: string, events: Event[], expectedVersion: number): Promise<void>;
}
async function executeCommand(store: EventStore, command: any) {
const events = await store.loadStream(command.id);
const state = replay(events);
const newEvents = handle(state, command);
await store.appendStream(command.id, newEvents, events.length);
}
// ===== Projections (read models) =====
// Build different views of the data from the same events:
class AccountSummaryProjection {
private summaries = new Map<string, { balance: number; closed: boolean }>();
async on(event: Event) {
const cur = this.summaries.get(event.id) ?? { balance: 0, closed: false };
if (event.kind === 'MoneyDeposited') cur.balance += event.amount;
if (event.kind === 'MoneyWithdrawn') cur.balance -= event.amount;
if (event.kind === 'AccountClosed') cur.closed = true;
this.summaries.set(event.id, cur);
}
}
// ===== Snapshots (perf) =====
// Replaying 10,000 events for every request is slow.
// Periodically save a SNAPSHOT of the aggregate state.
// On load: snapshot + events after snapshot.
// ===== When ES wins =====
// - Audit-heavy domains (banking, healthcare, legal)
// - Temporal queries ('what was the balance on 2024-04-10?')
// - Event-driven architectures (events as integration)
// - CQRS (often paired with read models)
// ===== When ES hurts =====
// - Simple CRUD apps
// - Teams without operational experience
// - Schema evolution (events are forever; you must support old versions)
// - Eventual consistency (read models lag)
// ===== Patterns to internalise =====
// - Events are FACTS in past tense
// - Apply is PURE; no side effects
// - Snapshots for performance
// - Multiple projections from the same stream
// ===== Pitfalls =====
// - Mutable state in apply functions
// - Forgetting expectedVersion -> lost updates / split brain
// - Schema changes on event types (use upcasters; never change history)
// - Pretending eventual consistency is strong (user reads-after-write)
Why it matters
Event Sourcing stores every fact, replays for state, and builds multiple projections. Pair with CQRS for read/write separation, snapshots for perf, and upcasters for schema evolution. Great for audit-heavy + temporal domains; overkill for simple CRUD.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Persist a log of events. Current state = fold over the log. // Replay = re-derive state. Audit = read the log.Try it Yourself »
Discussion
Loading…