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.
Что почитать дальше
- Гексагональная архитектура — как изолировать бизнес-логику от инфраструктуры.
- Тактические паттерны DDD — Aggregate и Domain Event, на которых строится write-модель.
- Стратегические паттерны DDD — Bounded Context и место CQRS в больших системах.