Store what happened, not what is. You get a perfect audit trail and time travel, and you give up the ability to run a simple query.
Prerequisites: CQRS, Event-Driven Architecture Time to read: ~24 minutes
A customer says their order total is wrong. You look:
SELECT * FROM orders WHERE id = 4821;
-- id: 4821, total_cents: 8500, status: 'shipped', updated_at: '2026-07-22'
That’s all you have. You know the current state and nothing about how it got there. Was there a discount? A returned item? A price correction? A bug? Who changed it, and when, and why?
🚨 Traditional storage destroys history on every update. UPDATE orders SET total = 8500
overwrites the previous value permanently. The audit trail — if there is one — is a bolted-on log
that’s incomplete and can drift from reality.
Event sourcing inverts it: store the events; derive the state.
OrderPlaced {items: [...], total: 10000}
DiscountApplied {code: "SAVE15", amount: 1500}
ItemReturned {sku: "SHOE-42", refund: 0}
OrderShipped {tracking: "..."}
────────────────────────────────────────────────
Current state (derived): total = 8500, status = shipped
The full history is the data. Nothing is ever overwritten.
The event store is an append-only log. Events are immutable facts. Current state is a fold over the events:
def current_state(events):
state = Order()
for event in events:
state = apply(state, event) # pure function
return state
Two operations:
order-4821).🚨 Concurrency control comes from the expected version:
event_store.append("order-4821", OrderShipped(...), expected_version=3)
# Fails if the stream is already at version 4 — someone else appended concurrently
This is optimistic concurrency applied to the stream, and it’s how invariants are protected without locking.
Snapshots solve the replay cost: periodically store the derived state at version N, and replay only events after it. An order with 50 events replays instantly; an account with 500,000 needs snapshots.
1. A complete, authoritative audit trail. 🚨 Not a log that might be incomplete — the audit trail is the data. There is no way for state to change without an event, because the event is the only mechanism. This is why event sourcing appears so often in finance, healthcare, and anywhere regulated.
2. Time travel. Reconstruct the state at any past moment:
state_at_month_end = fold(events_before("2026-06-30"))
“What did this look like when the customer complained?” becomes a query rather than an archaeology project.
3. New projections from old data. 🚨 This is the most underrated benefit. The product team asks for a metric nobody thought to track. In a traditional system, you start collecting it now and have no history. With event sourcing, you build a new projection and replay three years of events — the answer exists retroactively.
4. Debugging by replay. Reproduce a bug exactly by replaying the events that caused it. No guessing about intermediate states.
5. Intent is preserved. ItemRemovedFromCart and CartCleared both reduce the item count to
zero, but they mean completely different things. A state-based model records only “count = 0” and
loses the distinction — which is exactly the information a product analyst wants.
6. Natural fit for event-driven integration. The events you store are the events you publish. No dual write, no outbox needed.
⚖️ Event sourcing is a significant commitment. Be honest about it.
🚨 1. You cannot query it. “Find all orders over $100 shipped to Lahore last week” is impossible against an event log. Every query requires a projection, which means CQRS is effectively mandatory — and every new query need is a new projection to build, backfill, and maintain.
🚨 2. Schema evolution is permanent and hard. Events from three years ago must still be readable by today’s code, forever. You cannot migrate them (they’re immutable facts) — you must handle every historical version. Strategies: weak schemas (tolerate missing fields), upcasting (transform old events to the new shape on read), or versioned event types. All of them accumulate.
🚨 3. GDPR and the right to erasure. “Delete all of this user’s data” directly contradicts an immutable append-only log. Solutions exist — crypto-shredding (encrypt personal data per user and delete the key, rendering the events unreadable) is the standard one — but this needs designing in from the start, not retrofitting.
4. Eventual consistency everywhere, since reads come from projections. → CQRS
5. Replay cost grows. Long-lived aggregates need snapshots; rebuilding all projections after a change can take hours or days.
6. Unfamiliarity. Most engineers have never used it. Onboarding is slow, the tooling is thinner, and mistakes are expensive because the store is append-only.
7. Storage grows without bound. Every change is retained forever.
8. 🚨 Modelling mistakes are permanent. A badly-designed event is in the log forever, and every future version of your code must handle it. In a state-based system, a bad column is a migration.
Model events as business facts, not CRUD.
❌ OrderUpdated {field: "status", old: "pending", new: "shipped"}
✅ OrderShipped {tracking_number: "...", carrier: "DHL", shipped_at: "..."}
The first is a database changelog wearing a costume — it preserves no intent and consumers can’t do
anything meaningful with it. If your events are named XCreated, XUpdated, XDeleted, you have
built an audit log, not an event-sourced system, and you’ve taken on all the cost for none of the
benefit.
Events are immutable. No updates, no deletes. To correct a mistake, append a compensating
event (PaymentRefunded, OrderCorrected) — which is also more honest, because the mistake really
did happen.
Keep aggregates small. The aggregate is the consistency boundary and the replay unit. A “Customer” aggregate accumulating every event for ten years becomes unreplayable. Smaller aggregates with shorter lifecycles are far more manageable. → Service Decomposition
Design for schema evolution from day one. Version every event. Add fields only. Write upcasters when you must change shape. Assume today’s decisions are permanent.
Snapshot long streams. Every N events, store the derived state. Replay from there.
Separate internal events from published ones. 🚨 Your internal event schema is an implementation detail; publishing it makes it a public contract you can never change. Translate at the boundary.
A distinction worth being precise about:
| Audit log | Event sourcing | |
|---|---|---|
| Source of truth | The state table | The event log |
| Can state change without a log entry? | ✅ Yes — a bug, a manual fix, a migration | ❌ Impossible by construction |
| Completeness | Best effort | Guaranteed |
| Can you rebuild state from it? | Usually not | ✅ By definition |
| Cost | Low | High |
🎙️ If the requirement is “we need an audit trail,” an audit log is usually the right answer — far cheaper, and it satisfies the requirement. Event sourcing is warranted when you need the other benefits: retroactive projections, time travel as a first-class operation, or a domain where the history genuinely is the truth.
Saying this distinction out loud is a strong signal, because “we need auditability” is the most common (and usually insufficient) justification given for event sourcing.
Good fits:
| Domain | Why |
|---|---|
| Financial ledgers | The ledger is an event log. Balances are derived. This is how accounting has worked for 500 years. |
| Trading and order books | Sequence and intent matter; regulatory replay is required |
| Inventory and warehouse | “Why is stock wrong?” needs the full movement history |
| Insurance policies | Long-lived entities with a legally significant change history |
| Collaborative editing | Operations are the natural model → CRDTs |
| Regulated domains | Where you must prove what happened and when |
Poor fits:
🚨 The best practical advice: apply it to one bounded context, not the whole system. Event-source the ledger; use ordinary CRUD for user preferences. This is the pattern in successful real-world adoptions, and proposing it selectively rather than universally is the mature position.
🎙️ “I’d event-source the payments ledger, where history is the actual requirement and the domain is naturally event-shaped. User profiles and product catalogue stay CRUD — event sourcing them would be cost with no benefit.”
| Gain | Cost | |
|---|---|---|
| Event sourcing | Complete history, time travel, retroactive projections, preserved intent | No queries without projections; permanent schema; GDPR conflict |
| Audit log instead | Cheap; satisfies most audit requirements | Incomplete; can’t rebuild state |
| Snapshots | Bounded replay time | Storage; snapshots must be versioned too |
| Small aggregates | Fast replay; clear boundaries | More aggregates to coordinate |
| One bounded context only | Benefits where they matter, cost contained | Two paradigms in one system |
OrderUpdated) — that’s an audit log with extra steps.1. Build a bank account. About 100 lines, and it makes everything concrete:
events = []
def apply(state, e):
if e.type == "Deposited": return state + e.amount
if e.type == "Withdrawn": return state - e.amount
return state
def balance():
return reduce(apply, events, 0)
Then add: an expected-version check for concurrency, a snapshot every 100 events, and a projection for “monthly statement.” Then build a second projection — “largest transaction per month” — and replay history to populate it. That retroactive capability is the benefit that’s hard to appreciate until you’ve done it.
2. Feel the query problem. With 10,000 accounts, answer “which accounts have a balance over $1,000?” You can’t — you must replay every stream or build a projection. That constraint is the main cost, experienced directly.
3. Break schema evolution. Store Deposited {amount: 100}. Later change it to
Deposited {amount: 100, currency: "PKR"}. Now replay the old events — your code must handle the
missing field forever. Write an upcaster.
4. Try GDPR erasure. Store a user’s name in events, then implement “delete this user.” Discover you can’t. Then implement crypto-shredding and watch it work.