CQRS is not a badge — it is an operational bill. Dual models mean dual migrations, dual failure modes, and eventual consistency bugs that support tickets will call “random.” Seniors introduce separate read models only when measured query pain exceeds that tax, with explicit lag SLOs and a rollback story.
⚡ TL;DR: Split when write-optimized schema cannot meet p95 read latency or fan-out joins without melting writers. Keep a single model until then. Measure lag, dual-deploy cost, and consumer breakage. Pair with Read-Your-Writes on Global Tables and DynamoDB Streams Outbox.
Score the complexity tax before you split
# cqrs/decision.py
from dataclasses import dataclass
@dataclass
class CqrsSignals:
write_p95_ms: float
read_p95_ms: float
read_qps: float
join_depth: int
projection_lag_slo_ms: float
team_can_own_two_schemas: bool
def should_split(s: CqrsSignals) -> tuple[bool, str]:
if not s.team_can_own_two_schemas:
return False, "no_owner_for_projection"
if s.read_p95_ms < 80 and s.join_depth <= 2:
return False, "single_model_still_fast"
if s.write_p95_ms > 100 and s.read_qps > 500 and s.join_depth >= 3:
return True, "write_path_contends_with_heavy_reads"
if s.projection_lag_slo_ms < 200 and s.read_qps > 2000:
return True, "hot_read_path_needs_denorm"
return False, "tax_not_justified"
❌ Splitting because a conference talk said “commands and queries must diverge” — that is fashion, not criteria.
Boundary patterns that earn their keep
| Pattern | When it pays | Hidden cost |
|---|---|---|
| Same DB, denormalized read tables | Heavy joins on OLTP | Trigger/CDC dual-write bugs |
| Event-sourced writes + projection | Audit + many read shapes | Replay tooling, lag SLOs |
| Separate store (OpenSearch / Dynamo) | Search / fan-out reads | Schema drift, reindex jobs |
| Per-tenant read replicas | Isolation | Failover and lag UX |
Projection contract with lag SLO
// cqrs/projection.ts
export async function projectOrderPlaced(evt: OrderPlaced) {
await readDb.upsert({
pk: `order#${evt.orderId}`,
customerId: evt.customerId,
total: evt.total,
status: "placed",
version: evt.version, // ✅ monotonic — reject older
projectedAt: Date.now(),
});
}
export async function assertLag(orderId: string, writeAt: number, sloMs = 500) {
const row = await readDb.get(`order#${orderId}`);
if (!row || row.projectedAt - writeAt > sloMs) {
metrics.hit("ProjectionLagBreach");
// ✅ Fall back to write-side get for read-your-writes sessions
return "fallback_write_model";
}
return "ok";
}
UI paths that just created an entity must tolerate lag — sticky sessions to the write model or wait-for-projection tokens beat lying to the user.
Operational gate: dual deploy tax
Before merging the split, answer:
- Who owns projection failures on-call?
- Can you rebuild the read model from events in < 4 hours?
- Do contract tests fail the PR when event fields drop?
- Is there a feature flag to serve reads from the write model?
If any answer is vague, keep one model and add covering indexes.
Closing checklist
✅ Dos
– ✅ Score latency, join depth, and ownership before splitting
– ✅ Publish projection lag SLOs and breach metrics
– ✅ Version projections; reject stale events
– ✅ Provide read-your-writes fallback for create flows
– ✅ Prove rebuild time from the event log
❌ Don’ts
– ❌ Don’t CQRS every CRUD microservice by default
– ❌ Don’t ignore dual-schema migration cost
– ❌ Don’t expose lag as “eventual” without UX handling
– ❌ Don’t let projections silently drop fields
– ❌ Don’t split when an index or materialized view would do
Related reading
- Read-Your-Writes on Global Tables: Survive Cross-Region Replication Lag
- DynamoDB Streams Outbox: Domain Events Without Dual-Write Failures
- EventBridge Schema Evolution: Additive Changes Without Breaking Consumers
- Saga Compensations Under Partial Failure
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.

Pingback: Feature Stores vs Inline Compute: Real-Time Decisions Without Training Skew - CheatCoders