CQRS Adoption Framework
Command Query Responsibility Segregation uses different models to update information and to read it. It is not an architecture for the whole system, and it does not require event sourcing or a message bus. This framework stages adoption so that each step is justified by an observed problem.
How it works
Commands express intent, such as ApproveInvoice, and return success or failure, not data. A command handler loads an aggregate, checks invariants, and persists changes. Queries bypass the domain model and read directly from stores shaped for the question. When stores differ, a projector updates the read side from change events, so the read side trails the write side by a small interval.
Implementation steps
- Start with logical separation: distinct command and query handlers over one database.
- Introduce read-optimised views or tables when queries become slow or awkward.
- Add asynchronous projection, driven by an outbox or change stream, only when load or isolation requires it.
- Make projectors idempotent and checkpointed, and keep a command to rebuild any read model.
- Handle lag in the interface: return the new resource version from commands and let the client poll or wait for that version on the read side.
- Define ownership: each read model has a named consumer and a documented freshness target.
Common pitfalls
- Separating stores before measuring a real bottleneck.
- Letting queries call command handlers or vice versa, which defeats the separation.
- Ignoring ordering in projections, so out-of-order updates overwrite newer data.
- Returning stale data right after a write with no user-facing mitigation.
- Duplicating business rules in projectors.
Metrics to watch
- Projection lag, measured as time between commit and read model visibility.
- Read-after-write miss rate on first query following a command.
- Projector error and retry rates.
- Read model rebuild duration.
- Query latency percentiles on the read side.
Example configuration
readModel: order-summary
source: orders.events
projector:
idempotencyKey: eventId
ordering: perAggregate
checkpoint: durable
freshness:
targetP95Seconds: 2
alertAfterSeconds: 30
rebuild:
allowed: true
command: rebuild order-summary
Practical rollout
Treat the decision as reversible by keeping the split logical for as long as possible. Record the observed problem that justifies each increment, such as a query that cannot be indexed well or a read load that threatens write latency. Revisit the decision after a quarter: if lag complaints exceed the performance gains, merge the models again. Teams that document freshness targets with product owners avoid most disputes.
Key takeaways
- Split logically first and physically only when measurements demand it.
- Design the user experience for read lag, not just the backend.
- Keep projectors idempotent, ordered, and rebuildable.
- Apply CQRS to specific components, not as a blanket rule.