Event Sourcing Explained
Store state as a sequence of events, rebuild it by replaying them, use snapshots, and know when event sourcing is worth it.
What is event sourcing?
Most applications store the current state: the accounts table says Ayesha's balance is 7,000. Event sourcing stores the history of changes instead — every event that ever happened to the account — and derives the current state by replaying them.
Traditional: accounts: { id: 1, balance: 7000 }
Event sourced: 1. AccountOpened {owner: "ayesha"}
2. MoneyDeposited {amount: 10000}
3. MoneyWithdrawn {amount: 4500}
4. MoneyDeposited {amount: 1500}
→ replay → balance = 7000
Your bank statement already works this way: the balance is the result of the transactions.
Building an event-sourced account
from dataclasses import dataclass, field
@dataclass
class Account:
owner: str = ""
balance: int = 0
version: int = 0
def apply(self, event: dict) -> None:
kind = event["type"]
if kind == "AccountOpened":
self.owner = event["owner"]
elif kind == "MoneyDeposited":
self.balance += event["amount"]
elif kind == "MoneyWithdrawn":
self.balance -= event["amount"]
self.version += 1
@dataclass
class EventStore:
streams: dict = field(default_factory=dict)
def append(self, stream_id: str, event: dict, expected_version: int) -> None:
stream = self.streams.setdefault(stream_id, [])
if len(stream) != expected_version: # optimistic concurrency control
raise RuntimeError(f"Concurrency conflict: expected v{expected_version}, stream is v{len(stream)}")
stream.append(event)
def load(self, stream_id: str) -> Account:
account = Account()
for event in self.streams.get(stream_id, []):
account.apply(event)
return account
store = EventStore()
def withdraw(account_id: str, amount: int) -> None:
account = store.load(account_id) # rebuild current state
if amount > account.balance: # business rule on the write side
raise ValueError("Insufficient funds")
store.append(account_id, {"type": "MoneyWithdrawn", "amount": amount}, expected_version=account.version)
store.append("acc-1", {"type": "AccountOpened", "owner": "ayesha"}, 0)
store.append("acc-1", {"type": "MoneyDeposited", "amount": 10_000}, 1)
withdraw("acc-1", 4_500)
store.append("acc-1", {"type": "MoneyDeposited", "amount": 1_500}, 3)
account = store.load("acc-1")
print(account)
try:
withdraw("acc-1", 50_000)
except ValueError as exc:
print("rejected:", exc)
try:
store.append("acc-1", {"type": "MoneyDeposited", "amount": 100}, expected_version=2) # stale writer
except RuntimeError as exc:
print(exc)
Output:
Account(owner='ayesha', balance=7000, version=4)
rejected: Insufficient funds
Concurrency conflict: expected v2, stream is v4
Key ideas in that code:
- State is never stored directly — it's rebuilt from events.
- Events are only appended, never updated or deleted.
- Optimistic concurrency — appending requires the expected version, so two users acting on stale state can't both succeed.
Snapshots
Replaying 10 events is instant; replaying 2 million is not. A snapshot saves the state at a version, so loading means "latest snapshot + events after it":
snapshot = {"version": 4, "state": Account(owner="ayesha", balance=7000, version=4)}
def load_with_snapshot(stream_id: str) -> Account:
state = Account(**vars(snapshot["state"])) # copy the saved state
for event in store.streams[stream_id][snapshot["version"]:]:
state.apply(event)
return state
print(load_with_snapshot("acc-1").balance)
7000
Snapshots are a performance optimisation — the events remain the source of truth.
Benefits
- Complete audit trail — every change, who made it and when; essential in finance, healthcare and compliance.
- Time travel — rebuild the state as it was on any date to debug or answer "what did we know then?".
- New read models from old data — with CQRS, replay history into a brand-new projection.
- Natural fit for event-driven systems — the events you store are the events you publish.
Costs and challenges
- Complexity — a different mental model for the whole team.
- Schema evolution — you must read events written years ago (next lesson).
- Querying — "all accounts with balance > 10,000" needs a projection; you can't query the event log directly.
- Deleting personal data (e.g. under privacy laws) conflicts with immutability — techniques include storing personal data separately or encrypting it per user and deleting the key ("crypto-shredding").
- Eventual consistency for read models.
When to use event sourcing
Use it when history is core to the business — ledgers, bookings, workflows with audit requirements, collaborative editing. Avoid it for simple CRUD where nobody will ever ask how the data got to its current state.
Common mistakes
- Storing commands instead of events — store what happened, not what was requested.
- Events that are too generic (
AccountUpdated {…entire object…}) — they lose business meaning. - Putting business logic in projections — rules belong on the write side.
- Event-sourcing the whole system instead of the parts that benefit.
Key takeaways
- Event sourcing stores an append-only log of events; state is derived by replay.
- Optimistic concurrency with expected versions prevents conflicting writes.
- Snapshots speed up loading; events stay the source of truth.
- Powerful for audit-heavy domains — but adds real complexity.