Low Level Design: Event Sourcing — Design and Patterns

Event sourcing stores the state of an application as a sequence of immutable events rather than the current state snapshot. Instead of “account balance = $500,” you store “account opened with $1000,” “withdrawal $200,” “deposit $100,” “withdrawal $400.” The current state is derived by replaying events. This pattern provides a complete audit log, enables time travel (rebuild state at any point in time), and naturally integrates with event-driven architectures. Banks, trading platforms, and audit-heavy systems use event sourcing for exactly these properties.

Event Store Design

An event store is an append-only database optimized for writing and reading sequences of events. Each event has: aggregate_id (the entity being modified), event_type (e.g., “OrderPlaced”), version (monotonically increasing per aggregate), event_data (JSON or Protobuf payload), and timestamp. The version enables optimistic concurrency control: when appending event version N+1, verify the current highest version is N — if another writer added an event first, reject with a concurrency conflict. This prevents lost updates without locking.

CREATE TABLE events (
  id            BIGSERIAL PRIMARY KEY,
  aggregate_id  UUID NOT NULL,
  event_type    VARCHAR(100) NOT NULL,
  version       INT NOT NULL,
  event_data    JSONB NOT NULL,
  metadata      JSONB,          -- trace_id, user_id, correlation_id
  created_at    TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE (aggregate_id, version)  -- optimistic concurrency control
);
CREATE INDEX ON events (aggregate_id, version);

-- Append event with optimistic concurrency check
INSERT INTO events (aggregate_id, event_type, version, event_data)
VALUES ($1, $2, $expected_version + 1, $3)
ON CONFLICT (aggregate_id, version) DO NOTHING
RETURNING id;  -- NULL if conflict (another writer won)

Aggregate Reconstruction and Snapshots

To get the current state of an aggregate, load all its events in version order and apply them: state = events.reduce(apply, initialState). For aggregates with thousands of events, this is slow. Snapshots solve this: periodically serialize the current aggregate state and store it as a snapshot. On load: fetch the latest snapshot, then replay only events after the snapshot version. Snapshot every N events (e.g., every 100). Store snapshots in a separate table or cache. This bounds aggregate load time to O(events since last snapshot) instead of O(total events).

Projections: Deriving Read Models

The event store is the source of truth but is not optimized for queries. Projections consume the event stream and maintain read-optimized views: an orders_by_customer table, a product_inventory table, a dashboard aggregate table. Each projection is an event consumer that updates its read model as events arrive. If the read model becomes stale or needs a new shape, replay all events from the beginning to rebuild it. This separates write concerns (append events) from read concerns (query optimized views) — the CQRS (Command Query Responsibility Segregation) pattern.

Event Upcasting: Schema Evolution

Events are immutable once stored, but event schemas evolve as business requirements change. When an event type adds a new field: old events in the store lack that field. Upcasting transforms old event versions to the current version on read. Each event type has a version chain: v1 → v2 → v3. The upcaster reads old event data and adds default values or renames fields. This allows the application to always work with the latest event schema while old events remain unchanged in storage. Never delete or mutate stored events — only add upcasters for schema evolution.

Key Interview Discussion Points

  • Event sourcing vs. CRUD: CRUD is simpler and appropriate for most systems; event sourcing adds complexity justified when audit trail, temporal queries, or event-driven integration are core requirements
  • Eventually consistent projections: projections may lag behind the event store — design UIs to show “processing” states during lag
  • Event sourcing + Kafka: publish events to Kafka for downstream consumers (projections, notifications, analytics) — Kafka becomes the distribution layer for the event store
  • Sagas and event sourcing: sagas are natural in event-sourced systems — each saga step emits an event consumed by the next step
  • EventStoreDB: purpose-built event store database with built-in projections, subscriptions, and optimistic concurrency; alternative to building your own on PostgreSQL
Scroll to Top