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-дашборд — каждому нужна своя проекция, и заставлять одну модель угодить всем — это дорого.
- Аудит и история изменений как требование. Здесь CQRS вместе с Event Sourcing решает задачу почти бесплатно.
- Команды работают над 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.
Заключение
CQRS — это не про моду и не про «делать как у Netflix». Это про честное признание факта: операции изменения и операции чтения предъявляют к данным очень разные требования, и одна модель на всё — это всегда компромисс. Иногда компромисс терпимый. Иногда — невыносимый.
Если у вас типовое CRUD-приложение и команда из трёх человек — оставьте CQRS в покое и спокойно живите с одной моделью. Если же домен сложный, нагрузка асимметричная, требований к проекциям много, а команда готова к eventual consistency — CQRS даст вам ту самую гибкость, ради которой стоит платить операционным усложнением.
И главное правило: не начинайте с CQRS. Начинайте с понятных границ, читаемой write-модели и спокойных запросов. CQRS появится сам, когда вы услышите от тимлида фразу «мы тут собираемся вынести чтение в отдельную базу, потому что иначе всё ляжет». Это и будет момент, когда паттерн перестанет быть теорией и станет инструментом.
PS. Если вы только что прочитали этот пост и собираетесь на завтрашнем созвоне предложить «давайте перепишем всё на CQRS» — пожалуйста, не надо. Перечитайте раздел «когда CQRS только навредит», заварите чай и подумайте ещё раз. Серьёзно, чай иногда спасает архитектуру лучше любого паттерна.