CQRS Boundary Criteria: When Separate Models Finally Earn Complexity Tax

CQRS Boundary Criteria: When Separate Models Finally Earn Complexity Tax

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:

  1. Who owns projection failures on-call?
  2. Can you rebuild the read model from events in < 4 hours?
  3. Do contract tests fail the PR when event fields drop?
  4. 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

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

1 Comment

Leave a Reply