Specialization Frontend (React + TypeScript) — foundation ready. Open the frontend section →

CQRS

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

← назад к разделу

Когда приложение растёт, один и тот же объект начинают тянуть в разные стороны: запись хочет строгих правил и проверок, чтение хочет плоский ответ с десятью полями для таблицы. CQRS — способ прекратить этот конфликт, разделив их в разные модели.

Откуда берётся проблема

Представьте интернет-магазин. Когда покупатель оформляет заказ — нужно проверить остатки, применить скидки, убедиться что адрес доставки корректен. Это сложная бизнес-логика.

Когда покупатель смотрит список своих заказов — нужно просто вывести таблицу: дата, сумма, статус. Никаких проверок, никаких правил.

Если обе операции работают через одну модель, получается компромисс: модель перегружена деталями для чтения, которые мешают логике записи, или наоборот — упрощена ради чтения, и тогда приходится выкручиваться с бизнес-правилами.

CQRS (Command Query Responsibility Segregation — разделение ответственности команд и запросов) говорит: не надо компромиссов. Разделите изменение данных и чтение данных на две независимые модели и оптимизируйте каждую отдельно.

Команды и запросы

В CQRS все операции делятся на два типа:

Команда (Command) — меняет состояние системы. Примеры: CreateOrder, CancelOrder, UpdateProfile. Команда проходит через бизнес-логику, проверяет правила, сохраняет результат. Возвращает только идентификатор или статус — не сами данные.

Запрос (Query) — читает данные. Примеры: GetOrderById, ListRecentOrders. Запрос не меняет никакое состояние, не имеет побочных эффектов. Возвращает данные в том виде, который удобен конкретному экрану.

Это означает: если клиенту нужен созданный заказ — он делает два шага: сначала команду (создать), потом отдельный запрос (получить). Команда не возвращает полный объект заказа.

Две модели: write и read

Write-модель — строгая. Здесь живут все бизнес-правила. Данные хранятся в нормализованном виде, объекты скрывают своё внутреннее устройство и дают наружу только методы, которые проверяют корректность изменений.

Read-модель — удобная. Здесь данные денормализованы и подогнаны под конкретный запрос. Вместо того чтобы джойнить пять таблиц при каждом запросе списка — read-модель уже содержит готовую сборку. Это может быть отдельная таблица, материализованное представление, кэш или поисковый индекс.

Пример: write-модель хранит заказ в трёх таблицах (orders, order_items, customers). Read-модель для списка заказов — это одна таблица order_summary_view со всеми нужными полями уже собранными вместе.

Как это выглядит в коде

Поток записи: команда приходит в обработчик, обработчик загружает объект из write-хранилища, вызывает бизнес-метод, сохраняет изменения.

Поток чтения: запрос приходит в обработчик, обработчик читает прямо из read-модели одним простым запросом, возвращает результат без участия бизнес-логики.

// Поток записи
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();
    }
}

// Поток чтения
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);
    }
}

Обработчик команды работает с доменными объектами и бизнес-правилами. Обработчик запроса делает прямой SQL и возвращает простую структуру данных.

Как read-модель получает данные

Если write-модель и read-модель — это разные таблицы, как данные попадают в read-таблицу?

Есть несколько способов:

Через события. После сохранения изменений write-сторона публикует событие (OrderPlaced, OrderCancelled). Отдельный обработчик слушает эти события и обновляет read-таблицу. Это самый распространённый подход в CQRS.

Через триггер в базе данных. После вставки или изменения в write-таблицах триггер обновляет материализованное представление. Проще в реализации, но жёстче привязывает к конкретной базе данных.

Через фоновую задачу. Периодический процесс читает изменения и перестраивает read-модель. Подходит, когда небольшая задержка допустима.

Eventual consistency — данные не сразу свежие

Когда обновление read-модели происходит асинхронно (через события или фоновые задачи), между записью и видимостью результата есть задержка. Покупатель оформил заказ — а в списке своих заказов ещё секунду видит старую картину.

Это называется отложенная согласованность (eventual consistency): система в итоге придёт к правильному состоянию, но не мгновенно.

Для большинства экранов это нормально. Но если пользователь должен сразу увидеть результат своего действия — нужно либо читать из write-модели для этого случая, либо строить read-модель синхронно.

CQRS и Event Sourcing

Их часто называют вместе, но это два разных паттерна.

Event Sourcing — это способ хранить состояние объекта не как текущий снимок, а как последовательность событий, из которых снимок восстанавливается. Это отдельная большая тема.

CQRS можно применять вообще без Event Sourcing. И наоборот — Event Sourcing не требует CQRS. Они хорошо сочетаются, потому что лог событий удобно использовать для обновления read-моделей, но один без другого работает отлично.

Когда CQRS полезен

  • Нагрузка на чтение и запись сильно различается — их нужно масштабировать отдельно.
  • Доменная логика сложная, а запросы для UI хотят простую плоскую структуру.
  • Один и тот же объект нужен в разных экранах в разных формах.
  • Нужны отдельные хранилища: например, запись в PostgreSQL, а поиск в Elasticsearch.

Когда CQRS не нужен

  • Небольшой сервис с простым CRUD и невысокой нагрузкой.
  • Одна модель удовлетворяет и чтение, и запись без натяжки.
  • Команда не готова поддерживать две модели и синхронизацию между ними.

CQRS — это не серебряная пуля. Он добавляет сложность: больше кода, два хранилища, нужно следить за синхронизацией. Применяйте его когда выгода от разделения очевидна, а не на всякий случай.

Коротко

  • CQRS разделяет операции на команды (меняют состояние) и запросы (читают данные).
  • Команды проходят через бизнес-логику и возвращают только идентификатор, не данные.
  • Запросы читают из read-модели напрямую — без участия доменной логики.
  • Write-модель строгая и нормализованная; read-модель денормализованная и удобная для UI.
  • Read-модель обновляется через события, триггеры или фоновые задачи.
  • Отложенная согласованность — read-модель может отставать от write-модели на небольшое время.
  • CQRS и Event Sourcing — разные паттерны; каждый работает без другого.
  • Полезен при высокой нагрузке, сложной доменной логике и разных формах одних данных; не нужен при простом CRUD.

Что почитать дальше