CQRS & Event Sourcing
CQRS (Command Query Responsibility Segregation) separates the model used to change state (commands) from the model used to read state (queries). Event sourcing stores state as a sequence of domain events, rebuilding current state by replaying those events. They are independent patterns that often appear together — but CQRS does not require event sourcing, and event sourcing does not require microservices.
Both add cognitive and operational complexity. Justify them with requirements: divergent read/write shapes, auditability, replay, or extreme read scalability — not résumé-driven design.
In a traditional CRUD model, the same relational schema serves writes and every screen. When read needs diverge wildly — dashboards, search, personalized feeds — the write model becomes compromised by read optimizations or reads become painfully joined.
Write side
Validates commands; enforces invariants; updates write store or appends events.
Read side
Projections optimized for queries; eventually updated from write-side changes.
Sync integration
Same process updates both — simpler, less isolation.
Async integration
Events update read models — scales independently; eventual consistency.
| Requirement | CQRS help |
|---|---|
| Reads and writes scale differently | Scale read replicas/projections independently |
| Many read shapes from one write model | Multiple projections |
| Simple isomorphic CRUD | Usually unnecessary |
Instead of storing only the latest row state, store each change as an immutable event. Current state is a fold over events (often snapshotted). Benefits: complete audit trail, temporal queries, rebuild projections, debuggability of "how did we get here?"
- Why: compliance audit, complex domain with time-travel needs, rebuildable read models.
- How: append-only event store; aggregates apply events; projections subscribe; upcasters for evolution.
- Cost: versioning events, GDPR erasure strategies, operational expertise, harder casual debugging without tools.
| 1 | OrderPlaced(orderId, items, total) |
| 2 | PaymentCaptured(orderId, chargeId) |
| 3 | OrderShipped(orderId, tracking) |
| 4 | OrderCancelled(orderId, reason) |
| 5 | |
| 6 | Fold → current Order aggregate state |
| 7 | Projection → orders_by_customer read table |
| Cost | Manifestation |
|---|---|
| Cognitive load | More moving parts; harder onboarding |
| Consistency UX | Reads lag writes |
| Schema evolution | Event versioning/upcasting |
| Tooling needs | Admin viewers, reproject jobs |
| Privacy | Deleting PII from immutable logs |
danger
| Signal | Consider |
|---|---|
| Audit "who changed what when" is Must | Event sourcing or dense audit log |
| Read models need denormalization at scale | CQRS projections |
| Rebuild search index from history | Event log as source |
| Team new to domain + CRUD fits | Avoid — modular monolith CRUD |
- Start with CQRS-lite: separate query services/SQL views without full event sourcing.
- Introduce domain events for integration before storing all state as events.
- If event sourcing a subdomain, keep it inside a clear boundary (e.g., payments ledger).
- Build projection rebuild tooling on day one.
- Document consistency and privacy strategies in ADRs.
| 1 | export async function cancelOrder(cmd: {{ orderId: string; reason: string }}, store: EventStore) {{ |
| 2 | const history = await store.load(cmd.orderId); |
| 3 | const order = Order.fromEvents(history); |
| 4 | const events = order.cancel(cmd.reason); // domain decides |
| 5 | await store.append(cmd.orderId, events, history.version); |
| 6 | }} |
- Event sourcing CRUD entities with no domain behavior — expensive audit log.
- Huge events that embed entire DB rows every time.
- No snapshot strategy — replays become slow.
- Assuming CQRS means mandatory messaging everywhere.
- Ignoring GDPR: plan for crypto-shredding or minimized payloads.
| 1 | [ ] Divergent read/write requirements documented |
| 2 | [ ] Consistency lag acceptable to product |
| 3 | [ ] Privacy/retention strategy for events |
| 4 | [ ] Projection rebuild tested |
| 5 | [ ] Scope limited to subdomain with clear benefit |
| 6 | [ ] Team skills / tooling ready |
| 7 | [ ] ADR with alternatives (including "not now") |
| Variant | Description |
|---|---|
| CQRS-lite | Separate read DTOs/queries; same DB |
| CQRS + messaging | Async projections from events |
| CQRS + ES | Write model is event stream |
| Polarized | Only hottest subdomain uses CQRS |
Prefer the weakest variant that satisfies requirements. Most teams benefit from CQRS-lite long before full event sourcing.
Immutable logs conflict with erasure rights. Strategies include crypto-shredding (delete encryption keys for personal data), storing PII outside the log, or minimized events with references to erasable stores. Decide before production data accumulates.
warning
| 1 | Write: ShipPackage command → PackageShipped event |
| 2 | Read: customer tracking page uses denormalized tracking_view |
| 3 | Read: ops board uses warehouse_workload_view |
| 4 | Both projections consume the same events with different shapes |
You need projection lag metrics, rebuild jobs, event store backups, and admin tooling to inspect streams. Without these, the system is a research project. Budget platform time alongside feature work.
- Alert when projection lag exceeds UX-agreed thresholds.
- Practice rebuilding a read model in staging monthly.
- Document how to handle partial projection failures.
- Keep a runbook for "stuck consumer / poison event".
| Need | Try first | Escalate to CQRS/ES when |
|---|---|---|
| Faster reads | Indexes, cache, replicas | Models fundamentally diverge |
| Audit trail | Audit table / temporal tables | Full rebuild & behavior replay needed |
| Complex reporting | Warehouse ETL | Online serving needs same events |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.