Event Sourcing
Learn how event sourcing stores state as an immutable event log, enabling audit trails, time travel queries, and replayable projections at any scale.
TL;DR
- Event sourcing stores the full history of state changes as an immutable, append-only log of domain events. Current state is derived by replaying that log, not by reading a single mutable row.
- The core trade-off is query complexity vs. auditability: every read of current state requires replaying events (or querying a cached projection), but any past state is reconstructible, any projection is rebuildable, and bugs in read models are fixable without data migration.
- Snapshot strategy, aggregate boundaries, and event schema versioning are the three design decisions that determine whether event sourcing is maintainable or nightmarish in production.
- Event sourcing alone does not give you scalable reads. Pair it with CQRS: write-side aggregates append to an event store; read-side projectors consume events and build optimized query models.
- Only reach for event sourcing when audit trails, temporal queries, or multi-consumer event fan-out are actual requirements, not future hedges. The complexity is real.
The Problem
A bank processes 10,000 account transactions per day. At month-end, a compliance audit asks: "Show me every state change to Account #78901 in chronological order, who triggered each change, and what the system's understanding of the risk profile was at the moment of each change."
Your database has one row for Account #78901. It reads: balance: $4,230, status: ACTIVE, last_modified: 2026-03-26. Every UPDATE that ran against that row overwrote what came before. The history is gone. The compliance report is impossible without shipping audit-logging infrastructure you should have built from the start.
The same problem surfaces in less obvious places. Customer support asks: "A user says their order was cancelled but we still charged them β can you reconstruct the timeline?" Every UPDATE orders SET status = 'CANCELLED' destroyed the prior state. You are left reading application logs and hoping a developer left traces.
Traditional CRUD is optimized for current state. It answers "what is true now?" at low cost, by design. Every time you UPDATE or DELETE, you deliberately discard history.
At low scale, that is fine. At high scale, with compliance requirements, complex domain logic, or distributed consumers, that discarded history becomes a debt you pay in production incidents.
The silent audit gap most teams discover too late
Teams add explicit audit logging after the first compliance request they cannot fulfill. The problem: audit logging bolted on after the fact covers only the fields someone thought to instrument, and cannot retroactively reconstruct state from before it was added. Event sourcing makes the audit trail the primary data structure, not a secondary concern.
One-Line Definition
Event sourcing eliminates the audit gap and enables time-travel queries by persisting the state of a domain entity as an immutable, ordered sequence of domain events, where current state is derived by replaying that sequence in order (or from a snapshot checkpoint).
Analogy
Think of a bank's general ledger versus a simple savings account passbook.
A passbook shows one balance: $4,230. Simple to read, instant to query. But if you find an error, you cannot trace it back without an external record.
A general ledger records every single transaction: +$1,000 on March 1, -$200 on March 5, +$3,430 on March 20. The current balance is always the sum of all entries. The ledger is slightly more verbose to query, but the balance you compute is mathematically auditable and verifiable at any point in time.
Event sourcing is the general ledger for your software system. Your database stops being the passbook (current state only) and becomes the ledger (the full, immutable record of how you got here). A reasonable default is: use this analogy in your first 30 seconds when explaining event sourcing to an interviewer. It grounds the pattern in a business domain everyone understands immediately.
Solution Walkthrough
Here is what happens when a user places an order in an event-sourced system:
- Command arrives:
PlaceOrder(orderId: "o-789", customerId: "c-123", items: [...]) - Command Handler loads the aggregate: fetches all events for
orderId: o-789from the Event Store and replays them to reconstruct the currentOrderstate. - Aggregate validates the command: checks business invariants: is this customer active? are the items available? does this order already exist?
- Aggregate emits events: if validation passes, the aggregate produces
OrderPlaced(orderId: "o-789", totalAmount: 59.99, placedAt: "2026-03-26T09:00:00Z"). It does not write to a mutable database table. - Events are persisted to the Event Store: appended to the stream for
orderId: o-789. This write is atomic. The event is now the source of truth. - Event bus notifies consumers: the Event Store (or an outbox relay) publishes the event to subscribers. The order projector updates the read model. The inventory service reserves stock. The email service queues a confirmation.
- Read models are eventually consistent: projectors consume events asynchronously and update query-optimized views. A
GET /orders/o-789query hits the read model, not the event stream.
The right-hand side (projectors and read models) is what CQRS adds. An event-sourced system without CQRS forces every query to replay the event stream, which scales poorly. In practice, 95% of event sourcing implementations pair with CQRS for the read side.
Key Components
| Component | Role |
|---|---|
| Command | An instruction to do something. Named in present imperative tense. May be rejected if invariants are violated. |
| Domain Event | An immutable fact that something happened. Named in past tense. Always persisted. Never rejected after acceptance. |
| Aggregate | The consistency boundary. Loads its event stream, validates commands against current state, and emits new events. The smart part of the write side. |
| Event Store | The append-only database of events organized into streams (one per aggregate instance). Core operations: AppendToStream(streamId, events, expectedVersion) and ReadStream(streamId). |
| Projection / Read Model | A derived view built by consuming events. Rebuilt by replaying the event store. Can be any shape: SQL table, Redis hash, Elasticsearch document. |
| Snapshot | A serialized checkpoint of an aggregate's state at a given event sequence number. Eliminates replaying from event #1 on every aggregate load. |
| Event Bus | The pub/sub mechanism that distributes new events to subscribed projectors and other services. Can be in-process or distributed. |
| Upcaster | A transformation function that converts old event versions to the current version on read. Enables schema evolution without migrating stored event data. |
Implementation Sketch
// --- sketch ---
type OrderPlaced = { type: 'OrderPlaced'; orderId: string; customerId: string; items: OrderLine[]; totalAmount: number; placedAt: string; version: 1 };
type OrderCancelled = { type: 'OrderCancelled'; orderId: string; reason: string; cancelledAt: string; version: 1 };
type OrderEvent = OrderPlaced | OrderCancelled;
type OrderState = { orderId: string | null; status: 'PENDING' | 'PLACED' | 'CANCELLED'; totalAmount: number };
type PlaceOrderCommand = { orderId: string; customerId: string; items: OrderLine[]; totalAmount: number };
const initialState: OrderState = { orderId: null, status: 'PENDING', totalAmount: 0 };
// Apply a single event to state: pure function, zero side effects
function applyEvent(state: OrderState, event: OrderEvent): OrderState {
switch (event.type) {
case 'OrderPlaced':
return { orderId: event.orderId, status: 'PLACED', totalAmount: event.totalAmount };
case 'OrderCancelled':
return { ...state, status: 'CANCELLED' };
default:
// Forward-compatible: ignore unknown future event types
return state as OrderState;
}
}
// Reconstruct current state by replaying all events
function rehydrate(events: OrderEvent[], from: OrderState = initialState): OrderState {
return events.reduce(applyEvent, from);
}
// Command handler: load aggregate, validate, append event
async function placeOrder(command: PlaceOrderCommand): Promise<void> {
const streamId = `order-${command.orderId}`;
const events = await eventStore.readStream(streamId);
const currentState = rehydrate(events);
if (currentState.status !== 'PENDING') {
throw new DomainError(`Order $\{command.orderId\} already exists: ${currentState.status}`);
}
const newEvent: OrderPlaced = {
type: 'OrderPlaced',
orderId: command.orderId,
customerId: command.customerId,
items: command.items,
totalAmount: command.totalAmount,
placedAt: new Date().toISOString(),
version: 1,
};
// Optimistic lock: fails if another writer appended since we loaded
await eventStore.appendToStream(streamId, [newEvent], {
expectedVersion: events.length,
});
}
The expectedVersion check on appendToStream is the concurrency control mechanism. If two command handlers load the same aggregate simultaneously and both try to append at version 5, the second write fails because the stream is already at version 6. This is optimistic locking, equivalent to a SQL row version column, applied at the event stream level.
CQRS and Event Sourcing Together
These two patterns are independent but almost always paired. Treating them as the same thing is the most common interview mistake.
CQRS without event sourcing: Commands update a normal Postgres table. A domain event is published via the Outbox pattern. Projectors consume it. Simpler to operate. The projector does not have access to the full historical context; only the current row state at the time of the update is available.
Event sourcing without CQRS: Every read loads and replays the aggregate's event stream. No separate read model. Works for small aggregates and low read frequency. Falls apart at scale. Replaying 1,000 events per query at 500 req/s is not a production architecture.
Event sourcing with CQRS: The standard combination. The write side is an event store; the read side is projectors building optimized views for each query shape.
Each projection can be SQL, Redis, or Elasticsearch, shaped independently for its query pattern. When a projector has a bug, fix the code and replay the event stream to rebuild a clean view. No data migration needed.
The operational superpower here is projection replayability. If your Order Projector computed totals incorrectly for three months, fix the bug, spin up a shadow projector replaying from event one, validate it looks correct, and swap the table pointer. No data migration. Bugs in read models become deployment problems, not data corruption problems.
For your interview: say "I am adding a CQRS read side with separate projectors for each query shape, and if a projector has a bug, I replay the event stream to rebuild it cleanly." One sentence, then move on.
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.
Related Articles
Learn how CQRS separates reads from writes into independent models so each can be optimized, scaled, and evolved without the other paying the cost.
Learn how the Outbox pattern eliminates the dual-write problem in distributed systems, guaranteeing every database write produces its corresponding event even when brokers and services crash mid-flight.
Learn how the saga pattern maintains data consistency across microservices without distributed locks, and why compensating transactions are the key to surviving partial failure.