CQRS and Event Sourcing
Two patterns often discussed together. CQRS (Command Query Responsibility Segregation) separates reads from writes. Event Sourcing stores state as a sequence of events. They’re independent but pair naturally — and both are widely misapplied.
For DDD context see 12_domain_driven_design.md. For event-driven systems see 15_event_driven_saga.md.
CQRS — Command Query Responsibility Segregation
Greg Young, ~2010, building on Bertrand Meyer’s CQS (Command-Query Separation, 1988).
CQS at the method level: any method either changes state (command, returns void) OR returns data (query, no side effects). Never both.
CQRS at the system level: split the write model (commands) and the read model (queries) into separate paths, possibly separate databases.
Writes (Commands)
│
▼
┌────────────────┐
│ Write Model │
│ - Domain logic │
│ - Aggregates │
│ - Validations │
└────────────────┘
│
▼
┌────────────────┐
│ Write Store │ (e.g., normalized SQL)
└────────────────┘
│
events / sync
▼
┌────────────────┐
│ Read Stores │ (e.g., denormalized, optimized for queries)
│ - SQL views │
│ - Elasticsearch│
│ - Redis │
└────────────────┘
▲
│
Reads (Queries)
Why split
| Reason | Detail |
|---|---|
| Different load profiles | reads typically 10-100× writes; scale each independently |
| Different storage models | writes need normalized integrity; reads need denormalized speed |
| Different consistency needs | writes strictly consistent; reads can be eventually consistent |
| Different optimization | writes optimized for correctness; reads optimized for query patterns |
For a high-read system, the read model can be a denormalized view (e.g., “user with all their orders pre-joined”) in Postgres or Elasticsearch. Updates flow asynchronously from the write side.
A concrete example
# Write side — commands
class PlaceOrderCommand:
customer_id: CustomerId
items: list[ItemInput]
class OrderCommandHandler:
def handle(self, cmd: PlaceOrderCommand):
order = Order.create(cmd.customer_id, cmd.items)
self.repo.save(order)
self.event_bus.publish(OrderPlaced(order.id, ...))
# Read side — queries
class OrderListQuery:
customer_id: CustomerId
status_filter: list[OrderStatus] | None
class OrderListQueryHandler:
def handle(self, q: OrderListQuery) -> list[OrderSummary]:
return self.read_db.execute(
"SELECT order_id, total, status, created_at FROM order_summaries "
"WHERE customer_id = %s AND status = ANY(%s)",
q.customer_id, q.status_filter or [...],
)
The write side enforces domain rules through an aggregate. The read side queries a denormalized table optimized for the UI.
Pure CQRS vs “two-database CQRS”
Pure (light) CQRS: same database, two distinct code paths. Commands go through domain logic; queries go straight to read-optimized SQL views. Minimal infrastructure change; most of the benefit.
Two-database CQRS: separate write and read stores, kept in sync via events. Big infrastructure investment.
Most teams should stop at pure CQRS. Two-database CQRS adds operational complexity (event consumers, replication lag, eventual consistency UX) that’s only justified at scale.
When CQRS fits
- Read-heavy systems: read model can be aggressively denormalized.
- Complex domains (DDD): commands operate on aggregates; queries don’t need aggregates.
- Different teams own each side: clean separation reduces coordination.
- Multiple read views: search index + dashboard cache + reporting DB — feed all from one event stream.
When CQRS hurts
- Simple CRUD: separating reads and writes is extra code for the same outcome.
- Teams unfamiliar with the pattern: rolling out CQRS without strong understanding produces broken halves.
- Strong consistency required: eventual consistency between write and read sides breaks “read after write” expectations.
Event Sourcing
Instead of storing current state, store the sequence of events that produced it.
Traditional:
accounts table:
id | balance | status
1 | 100 | active
Event Sourcing:
events stream for account 1:
AccountOpened(1, owner="Alice")
Deposited(50)
Deposited(75)
Withdrew(25)
→ reconstruct: balance = 50+75-25 = 100, status=active
Current state is derived; events are the source of truth.
@dataclass(frozen=True)
class AccountOpened:
account_id: AccountId
owner: str
@dataclass(frozen=True)
class Deposited:
amount: Money
@dataclass(frozen=True)
class Withdrew:
amount: Money
class Account:
def __init__(self):
self.id = None
self.balance = Money.zero()
self.status = None
@classmethod
def from_events(cls, events):
acc = cls()
for event in events:
acc._apply(event)
return acc
def _apply(self, event):
if isinstance(event, AccountOpened):
self.id = event.account_id
self.owner = event.owner
self.status = AccountStatus.ACTIVE
elif isinstance(event, Deposited):
self.balance += event.amount
elif isinstance(event, Withdrew):
self.balance -= event.amount
def withdraw(self, amount: Money):
if amount > self.balance:
raise InsufficientFundsError
# Don't mutate; emit event
event = Withdrew(amount)
self._apply(event)
self._new_events.append(event)
The repository saves the new events; loading replays the event log to reconstruct state.
Snapshots
Replaying thousands of events on every load is slow. Periodically snapshot state and replay only events after the snapshot:
def load(account_id):
snapshot = snapshot_store.latest(account_id)
events_since = event_store.events_after(account_id, snapshot.version)
return Account.from_snapshot(snapshot).apply_all(events_since)
Snapshot every N events; balance the recompute cost vs storage.
Why Event Sourcing
- Audit log built-in: every change is recorded with full context.
- Time travel: reconstruct state at any past point.
- Easy projections: build new read models by replaying events.
- Natural integration: events flow to other systems for decoupled processing.
- Debugging: “how did this account get into this state?” — replay the events.
Why NOT
- Schema evolution is hard: old events have old shapes. New code must handle them forever.
- Eventual consistency: read models lag the event store.
- Storage: events accumulate. Snapshots help; full retention is expensive.
- Querying current state is slow: every load replays events. Add a read model.
- Complex to learn: developers used to “current state in a row” find this disorienting.
- Operational burden: backups, replay, migration of old events.
Event Sourcing != CQRS
You can do CQRS without Event Sourcing (write to normalized SQL; read from denormalized SQL). You can do Event Sourcing without CQRS (events are the only model; build a read store from them for queries — but you’ve effectively done CQRS).
In practice they pair: Event Sourcing for the write model; CQRS to provide queryable read models.
When to combine
The “full stack”:
Commands → write model → events stored to event log → projections → read models → queries
The write side is event sourced; the read side is CQRS-flavored projections.
This is a heavy stack. It pays off when:
- Audit / time-travel / regulatory requirements are real.
- Domain complexity warrants rich events.
- Multiple read models benefit from one event source.
It does NOT pay off when:
- Simple CRUD app.
- Team is small.
- Existing systems use traditional storage and integrate via simpler means.
Most “we need CQRS + Event Sourcing” sentiments turn out to be “we need a read replica” or “we need an audit log.”
Tools and patterns
- Event stores: EventStoreDB, Apache Kafka (as event log), Postgres with append-only events table.
- Frameworks: Axon (Java), Eventuate, eventsourcing (Python).
- In-house: Postgres + an
eventstable is enough for many use cases.
The simpler the better. Most “event sourcing” can be a Postgres table:
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL,
aggregate_type TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
version INT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (aggregate_id, version)
);
version for optimistic concurrency: when appending, check the latest version is what you expect.
Common pitfalls
- CQRS at the method level: every CRUD entity has
CreateXCommand,GetXQuery, etc. — ceremony with no payoff. - Event Sourcing without snapshots: 100k-event aggregates take seconds to load.
- Mutable event payloads: events are facts; never mutate them. Add new event types if the shape changes.
- Stuffing aggregates with events: aggregate that has 1M events is broken — split it.
- Read-after-write inconsistency: user submits a form, refreshes, sees old data. Mitigate with optimistic UI (assume the write succeeded) or read-after-write routing (force this user’s reads to the write store for a short window).
- Schema drift: events serialized with class names; you rename the class; old events can’t be deserialized. Use stable string tags, not Python class names.
- Event Sourcing for everything: simple entities (config flags, lookup tables) don’t need event sourcing.
Common interview confusions
- “CQRS means two databases.” — at the heavy end, yes. Light CQRS uses one DB with separate read and write code paths. Adopt incrementally.
- “Event Sourcing replaces databases.” — it’s a way of modeling state. You still use databases (event store + read stores).
- “CQRS and Event Sourcing are the same.” — different. CQRS = separate reads and writes. Event Sourcing = store events instead of current state. Often paired but independent.
Interview angle
- “What is CQRS?” — Command Query Responsibility Segregation. Separate the write model (commands going through domain logic) from the read model (queries hitting an optimized data structure). At the light end, two code paths in one DB. At the heavy end, two databases synced via events.
- “What’s the difference between CQS and CQRS?” — CQS (Meyer, 1988): method-level — methods are either commands (void return) or queries (no side effects), not both. CQRS (Young, ~2010): system-level — write model and read model are different code paths and potentially different stores.
- “When does CQRS pay off?” — read-heavy systems wanting denormalized read views; complex domains where aggregates make commands ergonomic; multiple read views fed from one source. NOT for simple CRUD.
- “What is Event Sourcing?” — storing state as a sequence of events instead of current state. Current state is reconstructed by replaying events. Built-in audit log, time travel, easy projections.
- “What’s a snapshot in Event Sourcing?” — periodic save of an aggregate’s current state. On load: read snapshot + replay events since snapshot. Avoids replaying thousands of events on every read.
- “How are CQRS and Event Sourcing related?” — they pair: write model is event-sourced; read models are projections of events (CQRS-style). But they’re independent — you can do CQRS without Event Sourcing (and vice versa).
- “What’s the read-after-write consistency issue?” — when reads use a denormalized view fed asynchronously from writes, the user might write and immediately read stale data. Mitigate with optimistic UI, sticky read routing to the write store, or just accepting the lag.
- “Why is Event Sourcing not always a good idea?” — schema evolution is hard (events live forever), querying current state requires replay or projections, storage grows, learning curve is steep. Worth it for genuine audit/compliance requirements; overkill otherwise.