Architecture Patterns

CQRS

● Advanced ⏱ 12 min read architecture

CQRS — Command Query Responsibility Segregation — is an architectural pattern that separates the model used to update data (commands) from the model used to read data (queries). Rather than a single model that handles both reads and writes, you maintain two distinct models optimized independently for their specific purpose.

The Problem CQRS Solves

In a traditional CRUD architecture, a single data model serves both reads and writes. This creates tension:

What Is CQRS

CQRS separates these concerns at the model level. Commands change state; queries read state. They use separate code paths, and in more advanced implementations, separate data stores.

CommandsQueries
PurposeChange stateRead state
Return valueTypically none (or just an ID)Data (DTOs, view models)
Side effectsYes — mutates state, raises eventsNone — pure reads
Model optimized forConsistency, invariants, business rulesQuery performance, shape of UI
ValidationFull domain validation before applyingNo validation — just fetch and return
CQRS: commands flow through the write model; queries use separate read models optimized for retrieval

Simple CQRS

The simplest form of CQRS separates command and query code paths within a single codebase and single database — no separate data stores. Commands go through domain objects with encapsulated business logic. Queries bypass the domain model and query the database directly, returning lightweight DTOs.

// Command path — through domain model
class PlaceOrderCommand {
  orderId: string
  customerId: string
  items: OrderItem[]
}

class OrderCommandHandler {
  handle(cmd: PlaceOrderCommand) {
    const order = Order.place(cmd.orderId, cmd.customerId, cmd.items)
    // validates, raises OrderPlaced event, enforces invariants
    this.orderRepository.save(order)
  }
}

// Query path — direct DB read, no domain model
class OrderQueryService {
  getOrderSummary(orderId: string): OrderSummaryDTO {
    return this.db.query(
      'SELECT o.id, o.status, c.name, SUM(i.price) as total FROM orders o ...',
      [orderId]
    )
  }
}

This is a significant improvement over CRUD without any operational overhead. The domain model stays clean; queries stay simple. Most teams implementing CQRS for the first time should start here.

CQRS with Separate Data Stores

The more powerful (and complex) form uses separate databases for reads and writes. The write database is normalized and optimized for consistency. The read database is denormalized and optimized for query performance — different schema, possibly a different database technology.

How synchronization works:

  1. A command is processed. The write model updates the write database and publishes a domain event.
  2. An event handler (projection builder) receives the event and updates the read database to reflect the change.
  3. Read queries go directly to the read database — no joins, pre-computed aggregations, query is fast.

Example — e-commerce order list:

Technology choices for the read store:

CQRS with Event Sourcing

CQRS pairs naturally with event sourcing. The event log (write model) is append-only and normalized to events. Read models (projections) are built by consuming the event stream. This is the full CQRS + ES stack:

The major benefit: because read models are built from events, they can be rebuilt at any time by replaying the event log. Change a projection’s schema, drop and rebuild from scratch. Add a new read model retroactively — it processes all historical events and is immediately up to date.

⚠️
CQRS + ES Is Complex

The full CQRS + Event Sourcing stack is powerful but introduces significant complexity: eventual consistency between write and read models, event schema evolution, snapshot management, and projection rebuild procedures. It’s a serious investment. Use it in domains where the benefits — auditability, retroactive queries, full history — justify that cost. Don’t use it as a default architecture for simple CRUD features.

Trade-offs

Eventual consistency. With separate read and write stores, there is a window (milliseconds to seconds) between a command being processed and the read model reflecting the change. A user who places an order and immediately navigates to "My Orders" might not see it yet. This requires UI design that accounts for propagation delay — optimistic updates, loading states, or explicit refresh prompts.

Complexity of maintaining two models. Two code paths, two data stores, synchronization logic, and projection builders are more code to write and more infrastructure to operate. The simpler single-model CRUD approach should always be considered first.

Testing complexity. With separate models, tests must cover the write path, the projection update, and the read path independently and together. Integration tests that verify the full command → event → projection → query cycle are essential.

Consistency within a command. Since reads use the read model (eventually consistent), a command handler that needs to read current state to make a decision must read from the write model, not the read model. Mixing read and write models in a command handler is a common mistake.

Design Considerations