CQRS

Простыми словами: что такое CQRS, зачем разделять команды и запросы, как устроены write-модель и read-модель, что такое eventual consistency и когда CQRS стоит применять, а когда лучше обойтись без него.

← back to section

As an application grows, the same object gets pulled in different directions: writes want strict rules and validation, reads want a flat response with ten fields for a table. CQRS is a way to end this conflict by splitting them into separate models.

Where the problem comes from

Imagine an online store. When a customer places an order, you need to check stock, apply discounts, make sure the delivery address is valid. That's complex business logic.

When a customer views the list of their orders, you just need to show a table: date, total, status. No checks, no rules.

If both operations run through a single model, you get a compromise: the model is overloaded with details for reads that get in the way of the write logic, or the other way around — it's simplified for reads, and then you have to work around the business rules.

CQRS (Command Query Responsibility Segregation) says: no compromises needed. Split changing data and reading data into two independent models and optimize each one separately.

Commands and queries

In CQRS every operation falls into one of two types:

Command — changes the state of the system. Examples: CreateOrder, CancelOrder, UpdateProfile. A command runs through business logic, checks rules, and persists the result. It returns only an identifier or a status — not the data itself.

Query — reads data. Examples: GetOrderById, ListRecentOrders. A query changes no state and has no side effects. It returns data in the shape that's convenient for a specific screen.

This means: if the client needs the created order, it takes two steps — first the command (create), then a separate query (fetch). The command does not return the full order object.

Two models: write and read

The write model is strict. This is where all the business rules live. Data is stored normalized, objects hide their internal structure and expose only methods that validate the correctness of changes.

The read model is convenient. Here data is denormalized and tailored to a specific query. Instead of joining five tables on every list request, the read model already contains a ready-made assembly. It can be a separate table, a materialized view, a cache, or a search index.

Example: the write model stores an order across three tables (orders, order_items, customers). The read model for the order list is a single order_summary_view table with all the needed fields already assembled together.

What it looks like in code

Write flow: a command arrives at a handler, the handler loads the object from the write store, invokes a business method, and persists the changes.

Read flow: a query arrives at a handler, the handler reads straight from the read model with one simple query and returns the result without any business logic involved.

// Write flow
public record CreateOrderCommand(UUID customerId, List<OrderItem> items) {}

public class CreateOrderHandler {
    public OrderId handle(CreateOrderCommand cmd) {
        Customer customer = customerRepo.find(cmd.customerId());
        Order order = customer.placeOrder(cmd.items());
        orderRepo.save(order);
        events.publish(new OrderPlaced(order.id(), order.total()));
        return order.id();
    }
}

// Read flow
public record OrderSummary(UUID id, String customer, BigDecimal total, String status) {}

public class OrderQueryHandler {
    public List<OrderSummary> findRecentByCustomer(UUID customerId, int limit) {
        return jdbc.query("""
            SELECT id, customer_name, total, status
            FROM order_summary_view
            WHERE customer_id = ?
            ORDER BY created_at DESC LIMIT ?
            """,
            (rs, rowNum) -> new OrderSummary(
                rs.getObject("id", UUID.class),
                rs.getString("customer_name"),
                rs.getBigDecimal("total"),
                rs.getString("status")
            ),
            customerId, limit);
    }
}

The command handler works with domain objects and business rules. The query handler runs direct SQL and returns a simple data structure.

How the read model gets its data

If the write model and the read model are different tables, how does data get into the read table?

There are several ways:

Through events. After persisting changes, the write side publishes an event (OrderPlaced, OrderCancelled). A separate handler listens to these events and updates the read table. This is the most common approach in CQRS.

Through a database trigger. After an insert or update in the write tables, a trigger updates the materialized view. Simpler to implement, but ties you more tightly to a specific database.

Through a background job. A periodic process reads the changes and rebuilds the read model. Suitable when a small delay is acceptable.

Eventual consistency — data isn't fresh instantly

When the read model is updated asynchronously (through events or background jobs), there's a delay between the write and the result becoming visible. The customer placed an order — and for another second they still see the old picture in their order list.

This is called eventual consistency: the system will eventually reach the correct state, but not instantly.

For most screens this is fine. But if the user must immediately see the result of their action, you need to either read from the write model for that case or build the read model synchronously.

CQRS and Event Sourcing

They're often mentioned together, but they're two different patterns.

Event Sourcing is a way to store an object's state not as a current snapshot but as a sequence of events from which the snapshot is reconstructed. That's a separate, big topic.

CQRS can be applied entirely without Event Sourcing. And the reverse — Event Sourcing doesn't require CQRS. They pair well because an event log is convenient for updating read models, but each works perfectly fine without the other.

When CQRS is useful

  • The read and write load differ significantly — they need to scale independently.
  • The domain logic is complex, while the UI queries want a simple flat structure.
  • The same object is needed on different screens in different shapes.
  • You need separate stores: for example, writes in PostgreSQL and search in Elasticsearch.

When CQRS isn't needed

  • A small service with simple CRUD and low load.
  • A single model satisfies both reads and writes without strain.
  • The team isn't ready to maintain two models and the synchronization between them.

CQRS is not a silver bullet. It adds complexity: more code, two stores, and you have to keep them in sync. Apply it when the benefit of separation is obvious, not just in case.

In short

  • CQRS splits operations into commands (change state) and queries (read data).
  • Commands run through business logic and return only an identifier, not data.
  • Queries read from the read model directly — with no domain logic involved.
  • The write model is strict and normalized; the read model is denormalized and convenient for the UI.
  • The read model is updated through events, triggers, or background jobs.
  • Eventual consistency — the read model may lag behind the write model for a short time.
  • CQRS and Event Sourcing are different patterns; each works without the other.
  • Useful under high load, complex domain logic, and different shapes of the same data; not needed for simple CRUD.

Further reading