vteh.extratechsolutions

Event Sourcing

Store state as an append-only log of immutable events, not a mutable row. Rebuild any past state, get audit for free, and pay for it in query and versioning complexity.

Demo coming soonadvancedevent-sourcingeventsaudittemporal-queries

Event Sourcing persists the facts that happened — an ordered, append-only sequence of immutable events — instead of the current state those facts imply. The current state becomes a derived value: fold the events and you have it. This inverts the default assumption of almost every system built on a database, and that inversion is the whole point. It is also the source of every operational headache the pattern brings, so treat it as a deliberate architectural commitment, not a default.

The problem

The default persistence model stores current state and overwrites it in place. A customer changes their shipping address; you UPDATE customers SET address = ? WHERE id = ?. The old address is gone. This is efficient and, most of the time, exactly what you want — until it isn't.

Three failures recur:

  • Destructive updates lose history. Every UPDATE and DELETE erases the prior fact. When someone asks why an account balance is what it is, or who changed a price and when, the answer no longer exists in the system of record. You are reconstructing intent from a snapshot that deliberately discarded it.
  • Audit gets bolted on after the fact. Because the requirement usually arrives late — compliance, a dispute, a security review — teams reach for triggers, audit tables, or change-data-capture. These are lossy shadows of the real events: they capture that a column changed but not the business action that caused it. A row going from active to suspended could be a fraud hold, a billing failure, or a manual admin action, and the audit table cannot tell you which.
  • "What did the state look like last Tuesday" is unanswerable. Temporal queries — reconstructing state as of a past moment, replaying a scenario, debugging "how did we get here" — are impossible against a store that only knows now. You can bolt on temporal tables, but you are re-deriving history you chose to throw away.

Event Sourcing addresses all three at the root: if the event log is the system of record, history is not a feature you add, it is the substrate. The catch is that everything else — querying, versioning, deletion, onboarding — now has to be reasoned about differently.

When to use it

Reach for Event Sourcing when the history itself has business value, not merely operational convenience:

  • Audit and compliance are first-class requirements, not nice-to-haves — finance, healthcare, trading, anything with a regulator who can demand "prove how this figure was derived."
  • The domain is genuinely event-shaped. Ledgers, order lifecycles, insurance claims, workflow/approval engines, and inventory movements are naturally sequences of discrete, meaningful facts. If your domain experts already talk in terms of events ("the order was placed, then paid, then partially refunded"), the model fits.
  • Temporal queries and replay carry weight — you need to reconstruct past state, run "what if" analyses, or debug production by replaying exactly what happened.
  • You want to derive new read models retroactively. Because events are the source of truth, you can build a projection you never anticipated and populate it by replaying the full history. This is one of the most under-appreciated wins: new questions get answered against old data.
  • You are already committed to CQRS, or the write and read sides have sharply different shapes. Event Sourcing and CQRS are separable but pair naturally (see Related patterns).

When NOT to use it

Be honest about the cost. In most systems, Event Sourcing is the wrong default. Avoid it when:

  • It's a CRUD app. If the domain is fundamentally forms over data with no meaningful history — a content admin, a settings panel, a catalog — Event Sourcing adds ceremony and buys nothing. Use a table.
  • The team is new to the pattern and on a deadline. Event Sourcing has a steep learning curve and unforgiving failure modes (a bad event schema is permanent). A team learning it under delivery pressure will ship a fragile store they cannot safely evolve. This is a competence-and-timeline judgment, and it is usually decisive.
  • You underestimate schema evolution. Events are immutable and live forever, so their shape lives forever too. Old events serialized last year must still deserialize next year. You will end up writing upcasters — functions that transform old event versions into current ones on read — and maintaining them indefinitely. This burden is real and permanent.
  • GDPR / right-to-erasure applies to the event payload. An append-only log fights directly with "delete this person's data." The usual answers — crypto-shredding (encrypt per-subject, then discard the key) or storing PII outside the event body — are workable but must be designed in from day one. Retrofitting erasure onto a naive event store is genuinely painful.
  • You need rich ad-hoc queries and won't build CQRS. The event log is superb for rebuilding a single aggregate and terrible for "all customers in region X with balance > Y." Without projections/read models, querying becomes agony. If you are not prepared to build and maintain those read models, the pattern will hurt.

Architecture

The write path is: a command expresses intent, an aggregate validates it against its current (folded) state and decides which events to emit, those events are appended atomically to the event store, and downstream projections and subscribers consume them to build read models and trigger side effects. Snapshotting is a read-side optimization that bounds rebuild cost.

flowchart TD
    C[Command] --> A[Aggregate]
    A -->|load| ES[(Event Store<br/>append-only)]
    A -->|decide + validate| EV[New Events]
    EV -->|append expectedVersion| ES
    ES --> P[Projections / Read Models]
    ES --> S[Subscribers / Side Effects]
    ES -.periodic.-> SN[(Snapshot Store)]
    SN -.load latest.-> A
    P --> Q[Query API]

Key properties encoded in the diagram:

  • The event store is append-only. Events are inserted, never updated or deleted. Ordering within a stream (an aggregate instance) is total and defined by a monotonic version number.
  • Appends carry an expectedVersion. This is optimistic concurrency: the append succeeds only if the stream is still at the version the aggregate loaded. A concurrent writer bumps the version and one of the two appends fails, to be retried. This is what keeps the log consistent without pessimistic locks.
  • Projections and subscribers are downstream and asynchronous. Read models are eventually consistent with the write side. This is a feature (scalability, independent evolution) and a hazard (stale reads) you must design for.
  • Snapshots are an optimization, never a source of truth. A snapshot is a cached fold at version N. Losing all snapshots costs replay time, never correctness — you can always rebuild from events.

For the store itself, three common choices, each with real trade-offs:

  • EventStoreDB — purpose-built: streams, subscriptions, and optimistic concurrency are native. Best fit if you can operate another stateful system.
  • Postgres append table — a single events(stream_id, version, type, payload, metadata) table with a unique (stream_id, version) index. Boring, operationally familiar, transactional, and adequate to surprisingly large scale. Often the right first choice.
  • DynamoDB + Streams — items keyed by (stream_id, version) with a conditional write for concurrency, and DynamoDB Streams for propagation. Scales horizontally but constrains query patterns and ties you to the platform.

Code walkthrough

Start with the events. They are immutable, past-tense facts. A discriminated union gives us exhaustive handling in the fold.

type AccountEvent =
  | { type: "AccountOpened"; owner: string; openedAt: string }
  | { type: "MoneyDeposited"; amount: number; at: string }
  | { type: "MoneyWithdrawn"; amount: number; at: string };

// A stored event wraps the domain event with stream position + metadata.
interface StoredEvent<E> {
  streamId: string;
  version: number; // monotonic within the stream, starts at 1
  event: E;
}

The aggregate is rebuilt by folding its event stream. when is a pure reducer: given prior state and one event, it returns the next state. There is no mutation and no I/O — this is what makes replay deterministic and testable.

interface Account {
  id: string;
  balance: number;
  version: number;
  open: boolean;
}

function when(state: Account, e: AccountEvent): Account {
  switch (e.type) {
    case "AccountOpened":
      return { ...state, open: true, balance: 0 };
    case "MoneyDeposited":
      return { ...state, balance: state.balance + e.amount };
    case "MoneyWithdrawn":
      return { ...state, balance: state.balance - e.amount };
  }
}

// Rebuild current state by folding history (optionally from a snapshot).
function rebuild(id: string, history: StoredEvent<AccountEvent>[], from?: Account): Account {
  const seed: Account = from ?? { id, balance: 0, version: 0, open: false };
  return history.reduce(
    (state, se) => ({ ...when(state, se.event), version: se.version }),
    seed,
  );
}

Writing is a decide-then-append cycle. The command handler loads the stream, folds it to current state, validates the command against that state (rejecting invalid intent — you cannot withdraw from an unopened account), and appends the resulting events with the expectedVersion it loaded. The store enforces concurrency.

async function withdraw(store: EventStore, id: string, amount: number): Promise<void> {
  const history = await store.load(id);
  const state = rebuild(id, history);

  // Invariant enforcement happens against folded state, not a stored row.
  if (!state.open) throw new Error("account not open");
  if (amount <= 0) throw new Error("amount must be positive");
  if (state.balance < amount) throw new Error("insufficient funds");

  const event: AccountEvent = { type: "MoneyWithdrawn", amount, at: new Date().toISOString() };

  // Optimistic concurrency: append only if the stream is still at state.version.
  // A concurrent writer that already advanced the version makes this throw,
  // and the caller retries the whole load-decide-append cycle.
  await store.append(id, state.version, [event]);
}

The append is where optimism becomes a guarantee. In Postgres, store.append inserts rows with version = expectedVersion + n; the unique (stream_id, version) index makes a losing race fail with a duplicate-key violation, which the handler maps to a concurrency error and retries. No row is ever locked for the duration of business logic.

On snapshots: replaying millions of events on every load does not scale. A snapshot is a serialized Account at a known version, stored separately. On load you fetch the latest snapshot, then fold only the events after it: rebuild(id, tail, snapshot). Snapshots are strictly an optimization — never authoritative, always regenerable from events — so a corrupt or missing snapshot degrades performance but can never corrupt state. Take them on a cadence (every N events) tuned so worst-case rebuild reads one snapshot plus a bounded tail.

Performance characteristics

The figures below are indicative — reasoned engineering estimates for a Postgres-backed store, not measured benchmarks from this system. Treat them as shapes to validate, not numbers to quote.

  • Append throughput and latency. A single-stream append is one indexed insert; p99 in the low-single-digit milliseconds is realistic. The ceiling per stream is the concurrency-control serialization point — one winning append per version, so a hot aggregate is a genuine bottleneck. Aggregate boundaries are therefore a performance decision as much as a modeling one: small, frequently-written aggregates contend.
  • Replay / rebuild time. Cold-loading an aggregate is a sequential read plus a fold. Cost is dominated by deserialization and upcasting, not disk. A stream of a few hundred events rebuilds in well under a millisecond; a stream of millions, without snapshots, becomes unacceptable — which is precisely why snapshots exist.
  • Snapshot cadence. The tuning knob is worst-case tail length. Snapshot every 50-500 events depending on write rate: too frequent wastes storage and write bandwidth, too sparse lets the replay tail grow. The goal is bounded, predictable load latency regardless of aggregate age.
  • Storage growth. Storage grows linearly and without bound — nothing is ever deleted. This is the defining cost. Plan for it up front: table partitioning by time or stream, cold-archival of closed aggregates, and a retention policy that respects your audit obligations. "Disk is cheap" is true right up until a single events table hits a billion rows and index maintenance becomes the operational story.

Live demo

An interactive Event Sourcing demo — appending events to a live stream, watching projections update, and time-travelling to reconstruct past state — is planned as part of the stop.procrastin.ar tool suite. Coming soon. It will let you drive commands against a real append-only store and inspect the resulting event log and derived read models side by side.

Related patterns

  • CQRS — the natural partner. Event Sourcing gives you a write model that is superb at capturing intent and hopeless at ad-hoc queries; CQRS supplies separate read models (projections) built from the event stream to answer those queries. They are genuinely separable — you can do CQRS with a conventional store, and you can source events without full CQRS — but in practice the query pain of Event Sourcing pushes most teams toward pairing them. See CQRS.
  • Publish/Subscribe — the mechanism for getting events out of the store to the rest of the system. Downstream services, projections, and integrations subscribe to the event stream and react. This is how Event Sourcing scales beyond a single aggregate into an event-driven architecture. See Pub/Sub.
  • Saga — once events flow between aggregates and services, coordinating a multi-step business process spanning several of them (with compensating actions on failure) is the Saga pattern's job. Event Sourcing supplies the durable, replayable event backbone a saga rides on.

Related patterns

Discussion