Most systems store the current state of the world and overwrite it on every update — an UPDATE orders SET status = 'shipped' erases whatever the row looked like a moment before. Event sourcing inverts this: the system of record is an append-only sequence of facts, and current state is a derived, replaceable view.
Storing Events Instead of State
An event is something that happened, named in the past tense and immutable once written: OrderPlaced, PaymentCaptured, OrderShipped. The event store never updates or deletes rows — it only appends.
CREATE TABLE events (
stream_id UUID NOT NULL,
sequence_no INT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (stream_id, sequence_no)
);stream_id groups events belonging to one aggregate (one order, one account); sequence_no enforces ordering and doubles as an optimistic concurrency check — an insert with a sequence number that already exists means someone else wrote first, and the writer retries against the new state.
Rebuilding State: Projections and Snapshots
Current state is just the fold of all events for a stream:
def rebuild_order(events: list[Event]) -> Order:
order = Order.empty()
for event in events:
order = order.apply(event)
return orderThis is honest but doesn't scale to a stream with 50,000 events. Two standard fixes: snapshots (periodically persist the folded state so replay starts from the last snapshot instead of event zero), and projections — separate, purpose-built read models kept up to date by subscribing to the event stream, each shaped for one query pattern rather than forcing one schema to serve all of them. This pairing is what most people mean when they say "event sourcing plus CQRS" — the events are the write model, projections are the read models.
Handling Schema Evolution
Events are immutable, but the code that interprets them isn't static — a payload shape from two years ago has to keep deserializing correctly. The two workable strategies:
- Upcasting: transform old event versions into the current shape at read time, keeping one canonical in-memory representation.
- Weak schema, tolerant reader: consumers read only the fields they need and ignore or default anything unfamiliar, so additive changes never break old readers.
What doesn't work well is versioning by rewriting history — the append-only guarantee is the entire point, so migrations happen by never touching old rows, not by "fixing" them.
When It's Worth the Cost
Event sourcing earns its keep when you need a true audit trail, when "what happened and in what order" is a business requirement (financial ledgers, inventory, compliance-heavy domains), or when multiple, differently-shaped read models genuinely need to stay in sync with one write path.
It's a poor fit for simple CRUD domains where nobody asks "how did we get here" — you pay real complexity (event versioning, projection lag, eventual consistency between write and read sides) for a history nobody queries. The tell is in the requirements, not the technology: if the business already asks for an audit log or a "replay this account" support tool, event sourcing is answering a question you were going to have to answer anyway.