Event-Driven Architecture Lesson 4 of 6 ⏱️ 5 min read

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.

Updated Oct 30, 2026

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.

Plaintext
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

Python
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:

Plaintext
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":

Python
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)
Plaintext
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.
Finished this lesson?
Tick it off to track your Event-Driven Architecture progress.
Next lesson