Event sourcing is sold as a silver bullet for audit, time travel, and rebuilding state. It is also operationally expensive and easy to do wrong. This post is an honest practitioner’s take.

What event sourcing is

Instead of storing current state, store the events that produced it.

CRUD:           account = { balance: 75 }
Event-sourced:  [Deposit 100, Withdraw 25] → balance derived as 75

Events are immutable, ordered, append-only. State is a function of events.

When it pays off

  • Financial / accounting — every change must be auditable.
  • Regulatory compliance — “what did the system know at time T?”
  • Complex domain with many invariants — events make domain logic explicit.
  • Time travel / audit is a first-class feature.
  • Multiple read models from the same write model (CQRS).

When it doesn’t

  • Simple CRUD — overhead with no benefit.
  • Small team — operational burden is real.
  • No audit / temporal query needs — CRUD is fine.
  • You haven’t proven the domain model — ES locks in event schema; refactoring is expensive.

For most apps: don’t event-source. For specific bounded contexts where it shines: do.

Event store

@dataclass
class Event:
    stream_id: str           # entity id
    version: int             # sequence within stream
    event_type: str
    data: dict
    metadata: dict
    occurred_at: datetime

Append-only table:

CREATE TABLE events (
    id          bigserial PRIMARY KEY,
    stream_id   text NOT NULL,
    version     int  NOT NULL,
    event_type  text NOT NULL,
    data        jsonb NOT NULL,
    metadata    jsonb,
    occurred_at timestamptz NOT NULL DEFAULT now(),
    UNIQUE (stream_id, version)
);
CREATE INDEX events_stream ON events (stream_id, version);

The unique constraint enforces optimistic concurrency: two writers can’t append at the same version.

Aggregate (write side)

class Account:
    def __init__(self):
        self.balance = 0
        self.version = 0
        self.uncommitted = []
    
    def deposit(self, amount):
        if amount <= 0: raise ValueError
        self._apply({"type": "Deposited", "amount": amount})
    
    def withdraw(self, amount):
        if amount > self.balance: raise InsufficientFunds
        self._apply({"type": "Withdrawn", "amount": amount})
    
    def _apply(self, event):
        match event["type"]:
            case "Deposited": self.balance += event["amount"]
            case "Withdrawn": self.balance -= event["amount"]
        self.uncommitted.append(event)
        self.version += 1
    
    @classmethod
    def load(cls, events):
        a = cls()
        for e in events: a._apply(e)
        a.uncommitted.clear()  # already committed
        return a

Load by replaying events. Apply commands → emit events. Persist uncommitted with optimistic version check.

Projections (read side)

For queries: a separate denormalized view, kept up to date by consuming events.

async def project_account_balances(event):
    match event["event_type"]:
        case "Deposited":
            await db.execute(
                "INSERT INTO balance_view (id, balance) VALUES ($1, $2) "
                "ON CONFLICT (id) DO UPDATE SET balance = balance_view.balance + $2",
                event["stream_id"], event["data"]["amount"]
            )
        case "Withdrawn":
            await db.execute(
                "UPDATE balance_view SET balance = balance - $1 WHERE id = $2",
                event["data"]["amount"], event["stream_id"]
            )

Reads hit balance_view; writes go through the aggregate. CQRS.

Snapshots

Replaying 10 years of events on every load = slow. Snapshot periodically:

async def load_account(stream_id):
    snap = await get_snapshot(stream_id)
    state = Account.from_snapshot(snap) if snap else Account()
    events_since = await get_events(stream_id, after_version=state.version)
    for e in events_since: state._apply(e)
    return state

Snapshot every N events; rebuild from snapshot + recent events. ~constant load time.

Replay

Want a new projection? Replay all events from the start:

async def replay():
    async for e in iterate_events():
        await new_projection(e)

Powerful: derive new views from history without re-running production. Painful: large event stores take hours.

Operational realities

  • Event schema evolution: events live forever. Adding fields is fine; renaming / removing requires migration or upcasting.
  • Idempotency: projections must be idempotent. Replay is normal.
  • Write throughput: append-only is fast, but a single hot stream can serialize.
  • Read latency: projections are eventually consistent. The order of events matters.
  • Storage: events grow forever. Plan retention or cold storage.
  • Debugging: replay → see exactly what happened. Big upside.

Tools

Strengths
EventStoreDBPurpose-built, mature, learning curve
Postgres + customDIY; works at moderate scale
Marten (Postgres-based, .NET)Best DX in .NET land
Axon (JVM)Full ES + CQRS framework
Kafka as event storeSometimes — has caveats (compaction, no per-stream version)

For most teams: Postgres + a small library. Don’t over-tool.

CQRS without event sourcing

You can have command/query separation without ES:

  • Commands write to normalized model.
  • Async projection populates a denormalized read model.

This is often the practical middle ground — gets you the read scaling without ES’s complexity.

Common mistakes

1. Event sourcing the whole app

Login, settings, billing, orders, posts — all event-sourced. Most don’t benefit. Bound it.

2. Events with implementation detail

{"type": "DBRowUpdated"} — that’s a CRUD event, not a domain event. Use domain language: OrderPlaced, PaymentCaptured.

3. No snapshots

Replays take forever. Snapshot every 100 events.

4. Synchronous projection

Each command waits for projection. Slow. Project asynchronously.

5. Mutable events

“Just edit the event to fix this.” Now your audit log is a lie. Append a corrective event instead.

Read this next

If you want my Postgres-based event sourcing starter, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .