CQRS на практике: когда стоит, а когда это просто два слоя для одного запроса
CQRS (Command Query Responsibility Segregation) — паттерн, про который чаще говорят, чем по-настоящему применяют. Идея простая: команды (изменяющие операции) и запросы (читающие) — это разные модели, разные слои, иногда разные хранилища. На бумаге звучит хорошо. В коде у трёх из четырёх команд, что я видел, CQRS выглядит как «ой, у нас CommandHandler и QueryHandler, но обе работают с одной таблицей» — и это не CQRS, это просто два слоя для одного запроса.
За двенадцать лет я внедрял CQRS в трёх вариантах сложности: «лёгкий» (разделение моделей в коде), «средний» (разные read-модели и проекции) и «тяжёлый» (CQRS+ES с разными базами под чтение и запись). Разберу, в каких случаях какой вариант оправдан, и где CQRS превращается в карго-культ.
В чём суть
В обычной архитектуре одна модель сущности обслуживает и запись, и чтение. Order-класс используется в обработчике POST /orders и в GET /orders. Одна и та же ORM-сущность, одни и те же поля.
Это работает, пока запросы не начинают расходиться по форме. Запись хочет богатую модель с инвариантами и валидацией. Чтение хочет плоский DTO для конкретной странички с заранее посчитанными агрегатами. Втиснуть оба в одну сущность — компромисс с обеих сторон.
CQRS говорит: разделите. Команды работают через одну модель (доменную, с инвариантами, обычно через репозиторий и агрегат). Запросы — через отдельную модель чтения, оптимизированную под use-case (плоский DTO, заранее агрегированные поля, иногда отдельная БД).
// Запись: команда + обработчик + агрегат
class CreateOrderCommand(val userId: UUID, val items: List<OrderItem>)
class CreateOrderHandler(private val repo: OrderRepository) {
fun handle(cmd: CreateOrderCommand): UUID {
val order = Order.create(cmd.userId, cmd.items)
repo.save(order)
return order.id
}
}
// Чтение: отдельный read-model
data class OrderListItem(
val id: UUID,
val userId: UUID,
val status: String,
val totalAmount: BigDecimal,
val itemsCount: Int,
val createdAt: Instant
)
class OrderListQuery(private val jdbc: NamedParameterJdbcTemplate) {
fun byUser(userId: UUID, limit: Int): List<OrderListItem> =
jdbc.query(
"""SELECT id, user_id, status, total_amount, items_count, created_at
FROM v_order_list
WHERE user_id = :uid
ORDER BY created_at DESC
LIMIT :lim""",
mapOf("uid" to userId, "lim" to limit),
::mapRow
)
}Запись — через объект, агрегат, репозиторий. Чтение — через простой SQL в DTO. Никакого ORM-маппинга на чтение, никакого Order-класса в QueryHandler.
Уровни CQRS
Три уровня, которые я различаю.
Уровень 1: Разделение моделей в коде
Самый простой и самый частый. Команды и запросы живут в разных классах, используют разные модели данных, но физически — та же БД, те же таблицы.
Что выигрываете:
- Чтение не тащит за собой инварианты домена. Запрос «список заказов с агрегатами» — это просто SQL, не
order.calculateTotal()на каждом элементе. - Команды чище: только бизнес-логика, без оптимизаций под чтение.
- Тестируется проще: команды тестируются через домен, запросы — через интеграционные тесты с БД.
Что не выигрываете: производительность БД. Запросы и команды бьют в одни таблицы, нагрузка та же.
Это разумный дефолт для большинства проектов. Я обычно с него и начинаю.
Уровень 2: Разные read-модели
Появляются отдельные таблицы или материализованные представления для чтения. Записи идут в нормализованные таблицы (orders, order_items), а чтение — из денормализованной (order_list_view: с уже посчитанным total и количеством items).
Эти view либо триггерятся на изменения исходных таблиц, либо обновляются по событиям.
-- Простейший вариант: материализованное представление
CREATE MATERIALIZED VIEW order_list_view AS
SELECT
o.id, o.user_id, o.status, o.created_at,
SUM(i.price * i.quantity) AS total_amount,
COUNT(i.id) AS items_count
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY o.id;
CREATE INDEX ON order_list_view (user_id, created_at DESC);Что выигрываете:
- Запросы быстрые. Никаких JOIN'ов и агрегаций на чтение.
- Запись не страдает от индексов под чтение.
- Можно построить специализированные представления под каждый use-case.
Что теряете:
- Eventually consistent: между изменением заказа и обновлением view проходит время.
- Сложность инфраструктуры: процесс обновления view надо мониторить.
- Дублирование данных: storage больше.
Уровень 3: Разные хранилища
Запись — в одной БД (например, Postgres), чтение — в другой (Elasticsearch, Redis, ClickHouse). Между ними — пайплайн, который слушает события и обновляет read-store.
Применяется в системах с принципиально разной природой запросов: запись — транзакционная, нормализованная, согласованная. Чтение — поиск с фасетами, аналитика по миллиардам строк, agregации в реальном времени.
Что выигрываете: реальная независимость масштабирования и оптимизации под каждую сторону.
Что платите: всё, что в уровне 2, плюс ещё одна БД, ещё один пайплайн, ещё одна точка отказа, ещё одна команда людей, которые её знают.
Когда какой уровень
Простые правила, которыми я пользуюсь.
Уровень 1 — почти всегда оправдан. Стоимость нулевая, профит — чище код. Я прихожу на проект и сразу вижу, есть ли разделение между обработчиками команд и обработчиками запросов. Если нет — рекомендую вводить.
Уровень 2 оправдан, когда:
- Чтения сильно перевешивают записи (10+ к 1).
- На некоторые запросы нужны агрегации/денормализация, которые на лету тормозят.
- Команда готова жить с eventual consistency на этом куске.
Уровень 3 оправдан, когда:
- Запись и чтение — принципиально разные технологии (например, OLTP-нагрузка vs full-text search).
- Объёмы данных такие, что одна БД физически не справляется с обеими ролями.
- Есть ресурс эксплуатировать два хранилища.
Если вы не можете чётко обозначить, что выигрываете на уровне 3, вы на нём не нужны.
CQRS и event sourcing
Часто путают. Это разные паттерны, и они независимы.
CQRS — про разделение моделей чтения и записи. Можно делать с обычной CRUD-БД на запись.
Event sourcing — про хранение истории изменений вместо состояния. Можно делать без CQRS, если у вас одна модель и для команд, и для проекций (но это редко).
Они хорошо сочетаются: event sourcing даёт «события», на которые натуральным образом подписываются проекции CQRS. Но не обязательны вместе.
Я внедрял CQRS без ES чаще, чем с ES. CQRS+ES — это уровень сложности, который требует серьёзного основания (про event sourcing я писал отдельно: его цена входа высокая).
Что обычно делают неправильно
Антипаттерны, которые я регулярно вижу.
Псевдо-CQRS на одной модели
Команды и запросы — отдельные классы, но обе работают с одной ORM-сущностью OrderEntity. Чтение мапит её в DTO «вручную», запись использует напрямую. Это не CQRS, это лишний слой бесполезной работы.
Лекарство: либо честное разделение моделей (DTO для чтения не зависит от ORM-сущности), либо отказ от CQRS на этом куске.
Команды-геттеры
«Получить статус заказа» оформлено как Command. Концептуально это запрос, но команда — потому что «у нас CQRS, всё через команды». Это путаница терминологии.
Команда = изменяющая операция, возвращает не данные, а подтверждение. Запрос = чтение, не меняет состояние, возвращает данные. Если ваша «команда» ничего не меняет — это не команда.
Шина команд ради шины
Внедряют MediatR/CommandBus и пропускают через него все операции. Каждый CRUD-эндпоинт — это команда, которая летит через шину к хендлеру. Лишний indirection, никакой пользы.
Шина оправдана, когда есть горизонтальные concerns (логирование, валидация, авторизация на уровне команд) и команд много. На пяти эндпоинтах — оверкилл.
Eventually consistent там, где не нужно
Команда «создать заказ» возвращает 202 Accepted. Через 500 мс заказ появится в read-модели. Фронтенд ждёт, делает poll. UX страдает, бизнес-польза неочевидна.
На уровне 1 CQRS этого нет: чтение видит изменение сразу, потому что таблица одна. На уровне 2-3 eventual consistency — это плата, и её надо обосновать use-case'ом, а не модой.
Практические советы
Несколько вещей, которые я бы хотел знать раньше.
Не вводите CQRS «на всю систему». Часть домена выигрывает, часть — нет. Заказы — да, профили пользователей — нет. Гибридная архитектура нормальна.
Read-модель — отдельный артефакт. Это не «view sql из commit'a». Это явно описанный класс/таблица с собственным контрактом, версионируемая, тестируемая.
Не превращайте read-модель в свалку. Один use-case — одна view. Не «одна таблица на все возможные запросы». Иначе она быстро становится недопустимо широкой и медленной.
Eventual consistency — UI-задача. Если read-модель отстаёт, UI должен это коммуницировать пользователю. Optimistic updates на стороне фронта, индикаторы синхронизации, retry — это всё нужно решить, иначе пользователь видит «я создал заказ, но в списке его нет».
Мониторьте лаг проекций. Метрика «отставание read-модели от write-модели» — критичная. Если оно стабильно растёт, значит, проекция не успевает, и пользователи видят несвежие данные.
Когда CQRS не нужен
Список симптомов «вам это не нужно».
- Простой CRUD-домен. Профили, настройки, справочники.
- Чтение и запись одинакового объёма, без специальных агрегаций.
- Маленькая команда, маленький продукт. Накладные расходы на разделение слоёв перевешивают пользу.
- Нет реальной боли с производительностью или сложностью моделей. Если боли нет, вы лечите несуществующую проблему.
Что запомнить
CQRS — это инструмент разделения. Базовый уровень (разные классы для команд и запросов) почти всегда оправдан. Средний и тяжёлый — только под конкретные требования: разная природа чтения и записи, разные объёмы, разные технологии.
Самый частый антипаттерн — CQRS «по фен-шую» без реальной выгоды. Если вы не можете назвать конкретную причину, по которой ваш проект выигрывает от разделения, вы делаете архитектуру для тренировки, а не для продукта. Хорошая архитектура — та, у которой ясная цена и ясная польза. CQRS не исключение.