CQRS

Why I Stopped Using the Same Model for Reads and Writes

In the POS system, I had a reporting endpoint that listed all orders with tenant info, item counts, total amounts, and payment status β€” joined across four tables. The same Order model I used for creating orders, updating them, and validating business rules was being used to drive this aggregated read.

The query was slow. The model was cluttered with fields that only made sense in reporting context. Every time I changed the write model to accommodate a new business rule, it risked breaking the read path.

CQRS (Command Query Responsibility Segregation) gave me the vocabulary to separate these concerns clearly: commands change state, queries read state, and they are allowed to use completely different models optimised for their purpose.

Table of Contents


The Core Principle

CQRS is based on Bertrand Meyer's Command Query Separation principle:

A method should either change the state of an object (command) or return a result (query), but not both.

CQRS applies this at the architectural level:

  • Command side: Handles mutations β€” create, update, delete. Uses a write model optimised for business rule enforcement and consistency.

  • Query side: Handles reads β€” lists, views, aggregations. Uses a read model optimised for the specific query's needs.

spinner

Commands vs Queries

Commands

A command is an intent to change state. It carries the data needed to perform the change and may be rejected if business rules are violated.

Commands return minimal data β€” typically an ID or a success/failure indication. They do not return the full updated object.

Queries

A query retrieves data without changing state. It is safe to call multiple times with the same result.


Implementation Approaches

Level 1: Same Data Store, Separate Models

The simplest application of CQRS: keep one database, but use different code paths (command handlers vs query handlers) and different models (write model vs read DTOs).

This is what I started with in the POS system β€” no data replication, just separation of concerns in the code.

Level 2: Separate Read Store

Project write-side events or CDC (change data capture) into a read-optimised store β€” a denormalised PostgreSQL view, a dedicated read table, or a Redis cache.

Level 3: Full CQRS with Event Sourcing

The write side uses event sourcing (state is stored as a sequence of events); projections build read models from those events. The most powerful and complex approach. See Event Sourcing.


Practical Example: Order System in the POS

Command Side

Query Side

The query side uses raw SQL optimised for the specific read. It does not go through the domain model. It returns a read-specific OrderSummary DTO, not an Order domain object.


Read Model Optimisation

In the list orders query above, I do a JOIN with users to get the cashier name. On the write side, the Order entity does not contain the cashier name β€” only the cashier_id. The read model is denormalised for the specific view it supports.

For reporting queries that need aggregations (total revenue per day, top-selling products), I maintain separate materialised read tables that are updated by a background job or event projection:

The DailySummaryQuery reads from this precomputed table β€” sub-millisecond response instead of scanning the entire orders table.


CQRS Without Event Sourcing

CQRS does not require event sourcing. The simplest form:

  • One database, one normalised schema for writes

  • Denormalised views, read-optimised queries, or a separate read table for reads

  • Synchronisation between write and read model via triggers, PostgreSQL views, or a background projection job

This is the approach I use in the POS system. It provides most of the query performance benefits without the operational complexity of event sourcing.


CQRS With Event Sourcing

When the write side uses event sourcing, the read model is built by projecting events:

The read model can be rebuilt any time by replaying all events from the beginning β€” a powerful debugging and migration tool.


When CQRS Is Worth the Complexity

CQRS makes sense when:

  • Read and write loads are asymmetric β€” many more reads than writes, or reads need different scaling than writes

  • Read queries are complex and slow β€” JOINs, aggregations, and reporting views that do not fit the write model

  • Domain model is rich with business rules β€” the write model enforces complex invariants that would make it awkward to use for reads

  • Multiple read representations of the same data β€” a list view, a detail view, a reporting view, all optimised differently

Avoid CQRS when:

  • The system is primarily CRUD with simple reads

  • The team is small and introducing two code paths doubles the surface area

  • There is no meaningful difference between the read and write models


Lessons Learned

  • Start with the simplest level. Separate command and query handlers in the same database first. Add a separate read store only when query performance demands it.

  • Command handlers are where domain rules live. Query handlers are where SQL craft lives. These are different skills.

  • The read model is allowed to be denormalised. Redundant data in a read table is intentional, not a design flaw.

  • Eventual consistency in the read model needs product sign-off. If the read model lags the write model by a second, someone needs to decide if that is acceptable.

  • Naming commands as intents and queries as questions makes the codebase self-documenting. CreateOrderCommand and ListOrdersQuery say exactly what they are.

Last updated