Detail chart · Backend

CQRS

Pattern ◆◆◆◆◇

Separate read and write models to optimise each path independently and reduce contention.

Summary

Command Query Responsibility Segregation (CQRS) splits an application’s data access model into two distinct sides: commands that mutate state, and queries that read state. Each side is optimized independently — writes enforce business invariants, reads serve UI and reporting needs without those constraints.

Problem

A single unified data model that handles both reads and writes creates competing pressures that are hard to resolve simultaneously.

Solution

Define two separate models: a command model that accepts mutations (create, update, delete) and enforces all domain invariants, and a query model (read model) that is optimized for the specific data shapes consumers need.

Client
  │
  ├─── Command ──▶ [ Command Handler ] ──▶ [ Write DB / Event Store ]
  │                       │                         │
  │                       │               [ Projection / Sync ]
  │                                                 │
  └─── Query  ──▶ [ Query Handler  ] ◀──  [ Read DB (denormalized) ]

Commands are intent-carrying messages (PlaceOrderCommand, CancelSubscriptionCommand). Command handlers validate input, apply domain logic, persist changes, and optionally emit domain events.

Queries bypass the domain model entirely — they read directly from a pre-projected read store optimized for the consumer. No business logic runs on the read path.

The two stores can be synchronized synchronously (same transaction, separate tables) or asynchronously (event-driven projections), depending on consistency requirements.

// Command side — enforces invariants
class PlaceOrderHandler {
  async handle(cmd: PlaceOrderCommand): Promise<void> {
    const cart = await this.cartRepo.findById(cmd.cartId);
    const order = cart.checkout(cmd.paymentMethod);  // domain logic
    await this.orderRepo.save(order);
    await this.eventBus.publish(order.pullDomainEvents());
  }
}

// Query side — returns exactly what the UI needs
class OrderSummaryQuery {
  async execute(userId: string): Promise<OrderSummaryDTO[]> {
    return this.db.query(
      `SELECT o.id, o.total, o.status, p.name AS product
       FROM order_summaries o JOIN products p ON o.product_id = p.id
       WHERE o.user_id = $1`,
      [userId]
    );
  }
}

The same split holds in Python, with the command handler enforcing invariants and the query handler reading straight from a denormalized store:

# Command side — enforces invariants
class PlaceOrderHandler:
    def __init__(self, cart_repo, order_repo, event_bus):
        self._cart_repo = cart_repo
        self._order_repo = order_repo
        self._event_bus = event_bus

    async def handle(self, cmd: PlaceOrderCommand) -> None:
        cart = await self._cart_repo.find_by_id(cmd.cart_id)
        order = cart.checkout(cmd.payment_method)  # domain logic
        await self._order_repo.save(order)
        await self._event_bus.publish(order.pull_domain_events())

# Query side — returns exactly what the UI needs
class OrderSummaryQuery:
    def __init__(self, db):
        self._db = db

    async def execute(self, user_id: str) -> list[OrderSummaryDTO]:
        return await self._db.query(
            """SELECT o.id, o.total, o.status, p.name AS product
               FROM order_summaries o JOIN products p ON o.product_id = p.id
               WHERE o.user_id = $1""",
            [user_id],
        )

Live Playground

Experiment with the pattern below. The two tabs show a monolithic service (before.ts) where commands and queries are tangled, and the CQRS-refactored equivalent (after.ts) with separate command and query handlers.

When to Use

Avoid when:

Trade-offs

BenefitCost
Read and write sides scale independentlyTwo models to maintain — more code surface area
Read model optimized for each consumerEventual consistency if projections are async
Write side free of reporting concernsSynchronization logic between stores adds complexity
Enables Event Sourcing as a natural extensionDebugging across split stores is harder
Clear audit trail when combined with domain eventsHigher initial setup cost than a single repository