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 | |
|---|---|
| EventStoreDB | Purpose-built, mature, learning curve |
| Postgres + custom | DIY; works at moderate scale |
| Marten (Postgres-based, .NET) | Best DX in .NET land |
| Axon (JVM) | Full ES + CQRS framework |
| Kafka as event store | Sometimes — 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
- CQRS and Event Sourcing 2026
- Kafka vs NATS vs RabbitMQ
- Idempotency, Retries, and Exactly-Once Illusions
- Temporal Workflow Engine 2026
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 .