CQRS
CQRS: разделение моделей чтения и записи. Когда применять, плюсы и минусы.
Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс.
Что такое CQRS
CQRS (Command Query Responsibility Segregation) — это паттерн, в котором операции изменения состояния и операции чтения разделены на две независимые модели:
- Command изменяет состояние системы:
create,update,delete. Возвращает только статус или идентификатор, но не данные домена. - Query читает данные. Никогда не меняет состояние, не имеет сайд-эффектов.
Идея простая: у чтения и записи разные требования к производительности, валидации и форме данных. Объединять их в одной модели часто означает компромисс по обеим осям. CQRS даёт возможность развести эти оси и оптимизировать каждую отдельно.
Базовые принципы
1. Команды не возвращают данные домена.
Команда CreateOrder возвращает OrderId, а не объект Order. Если клиенту нужны полные данные — он делает отдельный запрос через Query.
2. Запросы не меняют состояние. Никаких аудитных записей, обновлений last_seen, ленивых вычислений с побочными эффектами. Query — это идемпотентная читалка.
3. Разные модели для чтения и записи.
- Write-model: строгая доменная модель, инварианты, бизнес-правила. Часто нормализована, прячется за агрегатами.
- Read-model: денормализованная, оптимизированная под конкретный запрос UI. Может строиться материализованными представлениями, отдельными таблицами, поисковым индексом или кэшем.
Типичная архитектура
Поток записи:
- Команда приходит в обработчик
- Загружается агрегат из write-store
- Вызывается доменный метод, который проверяет инварианты
- Изменения сохраняются в write-store
- (Опционально) публикуется доменное событие
- Read-модель обновляется по событию
Поток чтения:
- Запрос приходит в обработчик
- Чтение прямо из read-модели (часто одним SQL-запросом)
- Возврат DTO без участия доменной логики
// Write side
public record CreateOrderCommand(UUID customerId, List<OrderItem> items) {}
@Component
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 side
public record OrderSummary(UUID id, String customer, BigDecimal total, String status) {}
@Component
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 ?
""", OrderSummary.class, customerId, limit);
}
}
Когда CQRS полезен
- много чтения и сложная выборка из разных таблиц
- нужно масштабировать чтение отдельно от записи
- доменная логика сложная, а UI хочет простой плоский ответ
- разные представления одних и тех же данных в разных экранах
Когда CQRS не нужен
- небольшой сервис, простой CRUD, низкая нагрузка
- одна модель удовлетворяет и чтение, и запись
- команда не готова поддерживать две модели и pipeline синхронизации
Преимущества
- отдельное масштабирование чтения и записи
- read-модель можно оптимизировать под UI без компромиссов в домене
- доменная модель не засоряется запросами «дай 10 полей для таблицы»
- проще разделять ответственность handler-ов команд и запросов
Недостатки
- больше кода: две модели, два хранилища, один pipeline между ними
- eventual consistency: read-модель может отставать от write-модели на миллисекунды или секунды; пользователь увидит «свежий» заказ не сразу после
POST - нужно строить и поддерживать pipeline обновления read-модели — это отдельный источник багов
CQRS и Event Sourcing
CQRS часто упоминается в одной строке с Event Sourcing, но это разные паттерны. CQRS можно применять и без событий — например, обновлять read-таблицу триггером в БД или асинхронным джобом, читающим лог изменений. Event Sourcing просто хорошо ложится на CQRS, потому что лог событий — естественный источник для перестроения read-моделей.
Ссылки
- Стратегические паттерны DDD — где CQRS применяется в больших системах с несколькими bounded context.
- Тактические паттерны DDD — Aggregate, Domain Event, на которых строится write-модель.
- Building blocks для Java — как реализовать write-side в Spring.