iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…