CQRS and Consistency
Separating reads from writes is a spectrum with five steps, and almost every service that gets hurt by it jumped to step four without needing steps one to three. Full asynchronous projections solve real problems; they also introduce eventual consistency into the user's own session, which is where the surprises are.
Two questions settle most decisions here: what force requires the read path to differ from the write path? and how stale may this data be, in seconds, for this specific use case? A read model with no answer to the second is not designed — it is just late.
Config Resolution
- Read
.msskills/config.yamlin the repo root; look uppaths.consistency. - A custom document exists at that path → read its frontmatter
mode:override→ use it alone; ignore the defaults below.overlay(default, or nomodekey) → read defaults first, then apply the custom document's sections on top, matched by exact heading; new sections append.
- A path is configured but no file exists there → say which path is missing, then use the defaults.
- No config file or no
paths.consistencykey → use defaults. .msskills/stack.mdexists → read its recorded CQRS level and datastore. Do not propose a step up the ladder without saying it is a step up, and why..msskills/context-map.mdexists → respect the recorded data ownership. A read model built from another context's data must follow the integration pattern the map records (seeservice-boundaries).
Self-Validation Checklist
STOP after designing or changing a read path, a projection, or anything that holds a copy of another service's data. Verify every check. Fix failures before presenting.
- LEVEL JUSTIFIED: Can you name the force that requires this level of separation — a read shape the write model cannot serve, or genuinely divergent read and write load? "Cleaner" or "more scalable" are not forces → collapse to one model.
- SMALLEST STEP: Is this the lowest level on the ladder that solves the problem? Jumping straight to asynchronous projections when a purpose-built query would do → step down.
- STALENESS BUDGET: For an asynchronous read model, is the acceptable lag stated as a number, monitored, and alerted on? An unstated budget means nobody can tell degradation from normal.
- READ-YOUR-WRITES: What does a user see immediately after their own write? If the answer is "the old value" → handle it explicitly: return the result from the command, read from the write model for that user, or wait on a version token.
- REBUILDABLE: Can this projection be rebuilt from its source, in a documented and exercised way? If not → it is a second source of truth and it will diverge.
- PROJECTION IDEMPOTENT: Does replaying the same event twice leave the same result? Projections
see duplicates and replays (see
idempotency-and-immutability). - POISON EVENT HANDLED: Does one unprocessable event stop the projection for everyone? → dead-letter it and continue, loudly.
- VERSIONED: Does changing the projection's logic rebuild into a new version and switch reads over, rather than mutating in place while consumers read?
- OWNERSHIP SINGLE: Does exactly one service write this data, and does nothing else touch its
store directly? (see
service-boundaries) - COPY JUSTIFIED: For a local copy of another service's data — is it a snapshot that must not change (price at time of order), or a replica that must stay fresh? These need different designs; conflating them is how stale prices get charged.
- CONSISTENCY STATED: Does the design say which operations are strongly consistent and which are eventual, and is that visible to the caller where it matters?
- EVENT SOURCING EARNED: If events are the source of truth — is there an actual audit, temporal, or replay requirement, and has the permanent cost of schema evolution, deletion, and snapshots been accepted?
All checks pass → state "Read path holds: level , staleness , rebuildable."
Active Anti-Pattern Scan
Any box you can check is a defect. Fix it before presenting.
- CQRS Cargo Cult: separate command and query stacks over one table with one shape → collapse to one model.
- Event Sourcing by Default: an event store adopted with no audit, temporal, or replay requirement → state-based persistence plus domain events through the outbox gives you publication without the permanent cost.
- Unbounded Projection Lag: an asynchronous read model with no stated budget, no metric, and no alert.
- Read-Your-Writes Broken: a user updates something and immediately sees the old value.
- Unrebuildable Projection: a read store that has drifted and can only be fixed by hand → make the rebuild a first-class, exercised operation.
- In-Place Projection Change: projection logic altered while it serves reads, leaving a mix of old and new rows.
- Silent Divergence: no reconciliation or comparison between a projection and its source, so drift is discovered by a customer.
- Stale Snapshot Treated as Live: a cached copy of another service's data used as if it were current → decide whether it is a snapshot or a replica and design accordingly.
- Shared Database: another service reading these tables directly → publish an API or events.
- Projection With Business Logic: rules applied while projecting that the write side does not enforce → the read model has become a second, unauditable implementation of the domain.
- Eventual Consistency Undisclosed: an API that returns success while the effect is still
pending, with nothing in the contract saying so (see
api-contracts).
Ambiguity Signals
Route these through collaborative-judgment. Each has two defensible answers.
- How far up the ladder to go. Each level buys capability and costs a consistency problem. The right level depends on load asymmetry you may not have measured yet — say when it is a guess.
- How stale a read model may be. Seconds are fine for a dashboard and unacceptable for a balance check. This is a product decision, not a technical one.
- Event sourcing. Genuinely right for audit-heavy, temporal, and replay-driven domains; a large and permanent cost everywhere else. The decision is effectively irreversible.
- Own a replica or call for the data. A replica removes a runtime dependency and adds staleness
plus a second source of truth to reconcile (see
service-boundaries). - Where the read model lives. The same database is simple and couples the two paths' capacity; a separate store scales independently and adds infrastructure.
- Synchronous or asynchronous projection. Updating in the write transaction keeps reads consistent and slows writes; asynchronous is the reverse.