CQRS — это не «модный паттерн». Это признание простой истины: чтение и запись — разные задачи, и не надо их пытаться обслужить одной моделью.
Если вы когда-нибудь писали API, где POST и GET на один ресурс держались за одну ORM-модель — поздравляю, вы знакомы с классическим CRUD. Рано или поздно приходит один и тот же момент: запросы на чтение растут в сложности (джоины, агрегаты, фильтры), запись начинает мешать чтению, а попытки оптимизировать читающий путь ломают пишущий. Иногда это происходит на шестой месяц проекта. Иногда — раньше.
На ревью кто-то бросает фразу «нам нужен CQRS», и половина команды нервно кивает. Про CQRS все слышали, но видели живьём — единицы. Давайте разберёмся: что это, зачем оно, и почему это точно не серебряная пуля.
Если вы ещё не читали обзор Event-Driven Architecture — сделайте это позже, CQRS расцветает именно в событийной среде. Общий контекст про моделирование домена даёт статья о Domain-Driven Design.
Что такое CQRS простыми словами
CQRS (Command Query Responsibility Segregation) — паттерн, при котором операции изменения состояния (commands) и операции чтения (queries) обрабатываются разными моделями, а в сложных случаях — разными сервисами и даже разными хранилищами.
Идею ещё в 1986 году сформулировал Бертран Мейер — принцип CQS (Command-Query Separation): метод либо что-то меняет, либо что-то возвращает, но не одновременно. CQRS берёт ту же идею и поднимает её с уровня методов на уровень архитектуры.
Грубо говоря: если CQS — про правила хорошего тона внутри класса, то CQRS — про разделение всей системы на два независимых конвейера:
- Write side (команды) — принимает изменения, валидирует бизнес-правила, обновляет состояние.
- Read side (запросы) — отдаёт данные, оптимизирована под чтение, обычно денормализована и заточена под конкретные UI-сценарии.
В классическом CRUD у вас одна модель Order — и для обновления заказа, и для отображения списка с подсчётом скидок и статистикой по клиенту. В CQRS это две разные модели. Одна знает про инварианты и бизнес-правила. Другая — про то, как удобнее показать данные во вкладке «Заказы».
Чем CQRS отличается от классического CRUD
Чтобы стало нагляднее — посмотрим на схему. Слева классический подход, где одна модель обслуживает всё. Справа CQRS, где write и read разнесены.
На схеме главное хорошо читается: в CRUD весь поток идёт через одну модель и одну базу. В CQRS — два независимых конвейера, связанных шиной событий или репликацией. Read DB можно перестроить с нуля, write DB оптимизировать под транзакции — и они друг другу не мешают.
Ключевые отличия в виде таблицы
| Аспект | Классический CRUD | CQRS |
|---|---|---|
| Модель данных | Одна на запись и чтение | Две независимые модели |
| База данных | Одна (обычно реляционная) | Можно две и больше (PostgreSQL + Elasticsearch, например) |
| Оптимизация | Компромисс между OLTP и OLAP | Каждая сторона оптимизирована отдельно |
| Сложность | Низкая | Существенно выше |
| Согласованность | Strong consistency «из коробки» | Eventual consistency между write и read |
| Когда уместно | 80% типичных бизнес-приложений | Сложный домен, неравный профиль нагрузки |
Из чего состоит CQRS: команды, запросы и хендлеры
Чтобы CQRS не висел в воздухе как абстракция из чужого учебника, разложим его на конкретные кирпичики.
1. Command (команда)
Команда — это намерение пользователя что-то изменить. Не «отправь HTTP-запрос», а бизнес-намерение в чистом виде:
PlaceOrder(оформить заказ)CancelSubscription(отменить подписку)RefundPayment(вернуть платёж)
Команда всегда формулируется в повелительном наклонении и имеет одно состояние ответа: принята или отвергнута. Никаких «верни мне обновлённую сущность» — это не задача команды.
2. Command Handler
Объект, который принимает команду, грузит агрегат из write-хранилища, проверяет инварианты, применяет изменения и сохраняет результат. Если знакомы с DDD — это именно тот слой, где живут агрегаты и доменные сервисы.
3. Query (запрос)
Запрос — это намерение пользователя что-то прочитать. Запросы возвращают DTO, заточенные под конкретный UI-сценарий: «список заказов клиента за месяц с суммой и статусом доставки», «детальная карточка заказа», «дашборд по продажам».
Важно: в CQRS запросы не должны менять состояние. Никаких «прочитал и попутно проставил last_viewed_at». Хотите фиксировать факт просмотра — это отдельная команда MarkOrderAsViewed.
4. Query Handler
Объект, который ходит в read-модель и достаёт нужные данные. Никаких бизнес-правил, никаких агрегатов. Только запрос к базе и маппинг в DTO. Часто это просто SQL или поиск в Elasticsearch.
5. Read Model (проекция)
Самая интересная часть. Read Model не повторяет структуру write-модели — она денормализована и хранит данные в формате, удобном под конкретные запросы.
Например, write-модель хранит Order, Customer, Payment как нормализованные таблицы. А read-модель хранит готовую проекцию OrderListView с уже подтянутым именем клиента, статусом платежа и суммой со скидкой. Никаких джоинов на лету — данные уже в нужной форме.
Чтобы поддерживать read-модель актуальной, write-сторона публикует доменные события, на которые подписаны проекции. Получили OrderCreated — добавили строку в OrderListView. Получили PaymentCompleted — обновили статус. Именно здесь CQRS смыкается с Event-Driven Architecture, и мы плавно переходим к следующей теме.
CQRS и Event Sourcing: почему их часто путают
Эти два паттерна часто упоминают вместе — и из этого рождается путаница. Давайте разберём отдельно.
Event Sourcing — паттерн, при котором текущее состояние системы не хранится напрямую, а восстанавливается из последовательности событий. Вместо «у заказа статус Paid» хранится:
OrderCreated → ItemAdded → ItemAdded → PaymentRequested → PaymentCompleted.
CQRS и Event Sourcing — это разные паттерны, но сочетаются они очень хорошо:
- CQRS можно делать без Event Sourcing — write-модель просто пишет в обычную реляционную БД, а проекции обновляются через триггеры или CDC (Change Data Capture).
- Event Sourcing можно делать без CQRS — но это редко имеет смысл, потому что восстанавливать сложные read-сценарии из событий по запросу мучительно дорого.
Когда они вместе, картинка становится особенно красивой:
На схеме хорошо видно, почему эти паттерны такие хорошие соседи. Write-сторона пишет события в Event Store, шина разносит их по проекциям, каждая проекция строит свою read-модель в подходящем хранилище. Аналитику в ClickHouse, оперативные списки в PostgreSQL, горячий кеш в Redis — пожалуйста.
Примечательно, что если завтра аналитики попросят новый отчёт — вы просто пишете новую проекцию и прокручиваете все события заново. Read-модель восстанавливается из истории. Ради этого, собственно, люди и связываются с Event Sourcing.
Когда CQRS оправдан, а когда — нет
Самая частая ошибка — внедрить CQRS «потому что красиво». Через полгода команда начинает ненавидеть этот паттерн всей душой, потому что любая мелочь требует обновления двух моделей и трёх проекций. Поэтому честно: CQRS уместен далеко не везде.
Когда CQRS реально окупается
- Сложный домен с богатыми бизнес-правилами. Если десятки инвариантов на агрегат и сложная валидация — отделение write-модели просто спасает.
- Сильно асимметричная нагрузка. Например, читаем в 100 раз чаще, чем пишем — типичный e-commerce каталог. Read-модель в таком случае масштабируется независимо.
- Разные форматы данных под разные UI. Мобильное приложение, веб-кабинет, BI-дашборд — каждому нужна своя проекция. Заставлять одну модель угодить всем дорого.
- Аудит и история изменений как требование. Вместе с Event Sourcing CQRS решает эту задачу почти бесплатно.
- Команды работают над write- и read-сторонами параллельно. Деление по моделям становится естественной границей ответственности.
Когда CQRS только навредит
- Простые CRUD-приложения. Админка, справочник, типовая внутренняя система — два слоя моделей создадут больше проблем, чем решат.
- Небольшая команда без опыта. CQRS требует дисциплины и понимания eventual consistency. Без этого в итоге получается неподдерживаемое месиво.
- Жёсткое требование strong consistency. Если бизнес не готов к тому, что после команды read-модель обновится через 50–500 миллисекунд — CQRS уже на старте создаёт проблему.
- Слабо сформулированный домен. Если вы ещё не уверены, какие bounded contexts у вас есть и что считается агрегатом — сначала разберитесь с этим. И только потом думайте про CQRS. Тут очень помогает DDD простыми словами.
Типичные подводные камни
Допустим, взвесили все «за» и решили: да, нам нужен CQRS. Отлично. Теперь о граблях.
1. Eventual consistency между write и read
Между моментом обработки команды и моментом обновления read-модели проходит время. Иногда миллисекунды, иногда секунды — если консьюмер тормозит. Классический сценарий «нажал кнопку → перешёл на следующую страницу → не вижу изменений» становится нормой.
Решается одним из трёх способов:
- UX-приёмы: «ваш заказ создан, обновление списка может занять пару секунд».
- Optimistic UI: на фронте сразу подкладываем ожидаемое состояние, не дожидаясь read-модели.
- Read-from-write для критичных сценариев: в редких случаях читаем напрямую с write-стороны.
2. Дублирование логики
Без дисциплины быстро получите два места, где «почти одно и то же»: валидаторы на write-стороне и что-то похожее на read-стороне. Дисциплина здесь простая: бизнес-правила живут только на write-стороне. Read-сторона ничего не валидирует — она только показывает.
3. Стоимость инфраструктуры
CQRS — это минимум +1 база данных, +1 шина событий, +1 пайплайн проекций, +1 набор алёртов. Операционная сложность растёт. Команды, которые недооценивают этот пункт, чаще всего и разочаровываются в паттерне потом.
4. Перестройка проекций
Рано или поздно захочется изменить формат read-модели. Есть Event Store — отлично: перепрогнали события и собрали новую проекцию. Нет Event Store — придётся либо мигрировать данные, либо писать сложные backfill-сценарии из write-БД.
5. Соблазн «всё через события»
CQRS работает лучше всего там, где уже есть культура работы с событиями и stateless-сервисами. Внедрять его поверх хаоса из синхронных вызовов и общих БД — больно. Сначала порядок в архитектуре, потом CQRS.
Заключение
CQRS — это не про моду и не про «делать как у Netflix». Это про честное признание факта: операции изменения и операции чтения предъявляют к данным очень разные требования, и одна модель на всё — это всегда компромисс. Иногда терпимый. Иногда — невыносимый.
Если у вас типовое CRUD-приложение и команда из трёх человек — оставьте CQRS в покое. Спокойно живите с одной моделью. Если же домен сложный, нагрузка асимметричная, проекций нужно много, а команда готова принять eventual consistency — CQRS даст ту самую гибкость, ради которой стоит платить операционным усложнением.
И главное правило: не начинайте с CQRS. Начинайте с понятных границ, читаемой write-модели и нормальных запросов. CQRS появится сам — когда тимлид скажет «мы тут собираемся вынести чтение в отдельную базу, иначе всё ляжет». Вот тогда паттерн перестанет быть теорией.
PS. Если вы только что прочитали этот пост и уже готовите речь на завтрашний созвон «давайте перепишем всё на CQRS» — пожалуйста, не надо. Перечитайте раздел «когда CQRS только навредит», заварите чай и подумайте ещё раз. Серьёзно, чай иногда спасает архитектуру лучше любого паттерна.