Event sourcing is a simple idea that is easy to describe and genuinely hard to live with: instead of storing the current state of a record, you store every state change as an immutable event, and you derive current state by replaying those events in order. The database row you're used to querying becomes a cache — a convenient, disposable projection — rather than the source of truth. The events are the source of truth.
State as a sequence of events
In a conventional CRUD model, an Order table has a status column, and an update overwrites it. History is gone unless you bolted on an audit table. In an event-sourced model, you never update the order row directly. Instead you append events — OrderPlaced, OrderLineAdded, OrderShipped, OrderCancelled — to an append-only log, keyed by the aggregate's ID. The current state of the order is whatever you get from folding all of its events, in sequence, through a function that knows how to apply each event type to the previous state.
This has a name in the domain-driven design world: the Order is an aggregate, and the events are grouped by aggregate ID (often called a stream). "Rebuilding an aggregate" just means reading its stream from the start and applying each event's mutation in order — no different, mechanically, from a functional reduce over a list.
type OrderEvent =
| { type: 'OrderPlaced'; customerId: string; at: string }
| { type: 'OrderLineAdded'; sku: string; qty: number; price: number }
| { type: 'OrderShipped'; at: string }
| { type: 'OrderCancelled'; reason: string };
function apply(state: OrderState, event: OrderEvent): OrderState {
switch (event.type) {
case 'OrderPlaced':
return { ...state, customerId: event.customerId, status: 'placed' };
case 'OrderLineAdded':
return { ...state, lines: [...state.lines, event], total: state.total + event.qty * event.price };
case 'OrderShipped':
return { ...state, status: 'shipped' };
case 'OrderCancelled':
return { ...state, status: 'cancelled' };
}
}
// Current state = fold every event in the stream, in order
const order = events.reduce(apply, emptyOrderState());
The event store
An event store is a database optimized for exactly one access pattern: append events to a stream, and read a stream back in order. It is not a general-purpose query engine. Purpose-built event stores (EventStoreDB, Marten on Postgres, Axon Server) give you that natively; plenty of teams build the same shape on top of a relational table with an auto-incrementing sequence per stream, or on a Kafka-compatible log with compaction disabled. The two properties that matter are immutability — events are never edited or deleted, only appended — and strict ordering within a stream, usually enforced with an optimistic concurrency check on the expected version number so two concurrent writers to the same aggregate can't silently clobber each other.
Writers append an event with the expected current stream version. If another writer got there first, the version has moved and the append is rejected — the caller re-reads and retries. This is what lets an event store handle concurrent writers without row-level locking.
Snapshots, because replay isn't free
Folding a stream from event zero works fine for an aggregate with a few hundred events. It stops working for one with a few hundred thousand — replaying on every read becomes the bottleneck. The standard fix is a snapshot: periodically persist the folded state at a given version, and on read, load the nearest snapshot and only replay the events after it. Snapshots are a performance optimization, not a second source of truth — you can always delete every snapshot and rebuild them by replaying from the beginning, and a correct event-sourced system should be able to prove that to you.
CQRS and read models
Event sourcing and CQRS (Command Query Responsibility Segregation) are frequently bundled together, but they're separable ideas. CQRS just means the model you write through and the model you read through are different. Event sourcing makes CQRS almost inevitable in practice, because a stream of OrderLineAdded events is a terrible thing to query directly — "show me all orders over $500 shipped last week" means folding every stream, which nobody wants on a request path. So you build projections: subscribers that consume the event stream and maintain denormalized, query-optimized read models (a SQL table, a search index, a cache) purpose-built for a specific screen or report. The read model is disposable — it's derived, deterministic output of a fold, so if it gets corrupted or you need a new shape, you replay the log into a fresh one.
Where it earns its keep, and where it doesn't
The pattern pays for itself when the history itself is a first-class requirement — audit trails, financial ledgers, anything where "what happened and in what order" matters as much as "what's true now," or where you need to support business logic that depends on temporal sequence (has this customer ever cancelled three orders in a row?). It's expensive overhead when a record's current state is genuinely all anyone will ever care about — a user's display name doesn't need an immutable event history, and modeling it as one is complexity with no return. The failure mode I see most often is teams adopting event sourcing because it sounds architecturally serious, then hand-rolling snapshotting, versioning, and projection rebuilds that a plain audited table would have given them for free.
| Concept | What it means |
|---|---|
| Event | An immutable fact that already happened, named in the past tense |
| Stream | The ordered sequence of events for one aggregate instance |
| Aggregate | The current state, derived by folding a stream |
| Snapshot | A cached fold at a point in time, purely a performance shortcut |
| Projection | A derived read model built by consuming the event stream |
Event sourcing is a trade: you give up the convenience of mutable state and cheap ad-hoc queries, and in exchange you get a complete, replayable history and the ability to derive as many read models as you need from a single source of truth. Whether that trade is worth it comes down to one question — does this domain actually need its history to be a queryable, first-class thing, or would a row that gets overwritten do just as well?
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.