Command Query Responsibility Segregation is a small idea wrapped in an intimidating name and frequently bundled with event sourcing, which is a separate decision. The idea: the model you use to change state and the model you use to read state do not have to be the same, and past a certain complexity they should not be.
The reason is that the two have opposing requirements. A write model wants invariants, small aggregates and normalised data. A read model wants denormalisation, precomputation and a shape that matches the screen. Serving both from one set of tables means a growing pile of joins and projections that make writes slower and queries more baroque.
The version worth adopting first
You do not need separate databases, message buses or event stores to get most of the value. The lightweight form is: commands go through your domain model with its rules and validation, and queries bypass it entirely, reading purpose-built projections or straight SQL tailored to the view. One database, two paths.
# Write path: rules, invariants, a small aggregate, no query concerns.
def approve_inspection(cmd: ApproveInspection, repo: InspectionRepo) -> None:
inspection = repo.get(cmd.inspection_id)
inspection.approve(by=cmd.actor, at=cmd.at) # raises if illegal
repo.save(inspection)
events.publish(InspectionApproved(...))
# Read path: shaped for the screen, no domain objects, no joins at request time.
def dashboard(tenant_id: str) -> DashboardView:
return db.fetch_one("""
SELECT open_count, overdue_count, avg_turnaround_hours, updated_at
FROM inspection_dashboard_projection
WHERE tenant_id = %s
""", tenant_id)
# The projection is maintained by the event, not computed per request.
@on(InspectionApproved)
def update_dashboard(event):
db.execute("""
UPDATE inspection_dashboard_projection
SET open_count = open_count - 1, updated_at = now()
WHERE tenant_id = %s
""", event.tenant_id)Eventual consistency is the cost, and it is a product decision
Once projections are updated asynchronously, a user can perform an action and not see it reflected immediately. For a dashboard refreshed every minute, nobody notices. For the list the user was just editing, they notice instantly and report it as a bug.
Handle it deliberately rather than hoping the lag stays small. Read your own writes from the write model for the specific flows where immediacy matters, update the client optimistically, or show the freshness explicitly. What does not work is treating the delay as an implementation detail — it is visible behaviour, and product needs to agree to it.
- Adopt it per bounded context, never across the whole system. Most contexts are simple enough that one model is correct.
- Start with synchronous projection updates in the same transaction, and move to asynchronous only when you need the decoupling.
- Make projections rebuildable from source. A projection you cannot regenerate is a second source of truth you did not intend to create.
- Monitor projection lag as a first-class metric, with an alert. Silent lag is how stale dashboards become an incident three days later.
- Do not adopt event sourcing at the same time unless you specifically need a full history. Two hard changes at once is how these projects stall.
CQRS is worth it when your reads and writes genuinely want different shapes. If you are adding it for elegance, you are adding a synchronisation problem for elegance.
The situations where it clearly pays: a read-heavy workload where one denormalised projection replaces a query with six joins, reporting that was degrading transactional performance, and complex domains where the write model is genuinely rich and the screens genuinely do not match it. Outside those, one model is simpler and simpler is the correct default.