Всем привет, меня зовут Сергей Прощаев. В этой статье я расскажу про CQRS (Command Query Responsibility Segregation) и его роль в Domain-Driven Design.
Многие команды в своем развитии проходят через активный рефакторинг монолита. Простая задача «добавить поле в профиль пользователя» превращается в квест на две недели, потому что нужно понять, где это поле сломает 15 отчётов и 20 интеграций?
В сети есть интересный пример, где одна команда описывает реализацию MVP для нового кредитного конвейера. Запустили, всё работало быстро, код был чистым. Но через полгода, когда бизнес попросил добавить пару новых статусов заявки и историю их изменений, монолит начал задыхаться. Одна и та же модель Order использовалась и для расчёта скоринга, и для отображения +100500 списков заявок в личном кабинете. Любое изменение в @Entity ломало выборки, добавление индексов спасало на пару дней. Тогда все задумались о подходе CQRS.
Проблема, которую решает CQRS
Если совсем просто: CQRS — это разделение ответственности на команды (изменение состояния) и запросы (чтение данных).
В классическом CRUD-подходе мы привыкли, что одна и та же сущность (модель данных) используется и для записи в базу, и для чтения. В маленьком проекте это удобно. Но когда предметная область начинает жить своей жизнью, появляются проблемы:
Разные требования к модели. Для сохранения заявки мне нужна валидация паспорта, проверка скоринга. Для её отображения — только ФИО и статус красивым цветом. Зачем тягать тяжёлый объект агрегата на каждый чих?
Блокировки и конкурентность. Пока бухгалтерия выгружает отчёт за год (долгий SELECT), пользователь не может зайти на сайт, потому что таблицы заблокированы. Знакомо?
Сложность запросов. Попробуйте натянуть отчёт с 20 условиями выборки на модель, оптимизированную для проверки бизнес-правил. Там начнутся джойны, которые похожи на спагетти.
CQRS предлагает развести эти пути. Команды работают со своей моделью (обычно это доменная модель из DDD), а запросы — со своей, часто денормализованной и заточенной под конкретные нужды UI.
Первый шаг: разделяем, но не усложняем
Сразу предупрежу: CQRS — это не серебряная пуля. Если у вас простой блог или интернет-магазин с пятью товарами, не надо городить огород. Мы вводим CQRS точечно, там, где есть та проблема, которую CQRS эффективно решает.
Возьмем классический пример из банковской сферы — обработка кредитной заявки.
Модель команд (Write Model): Это наш доменный агрегат. Он проверяет бизнес-правила: не превышен ли лимит, не мошенник ли заявитель, все ли документы приложены.
Модель запросов (Read Model): Это отдельные таблицы/проекции для:
Списка заявок в кабинете менеджера (нужны быстрые фильтры).
Детальной карточки заявки (нужна вся информация).
Отчёта для риск-менеджмента (нужны агрегированные данные).
Воспользуемся Mermaid и напишем простой код для диаграммы, чтобы представить как выглядит высокоуровневая архитектура такого решения (см рис. 1):

Документируем границы: с чего начинается архитектура
Как системный аналитик (а в душе всегда им остаюсь), начинаю не с кода, а с границ.
Когда стоит задача создать новый сервис нотификаций, и в ТЗ видишь: «Сделайте уведомления». Возникает вопрос: «А кому и зачем?». И на деле оказывается, что есть два совершенно разных потребителя:
Бизнес-процессы (команды): отправить письмо после подтверждения заявки.
Личный кабинет (запросы): показать историю уведомлений пользователю, с пагинацией и фильтрами.
Это идеальный кандидат на CQRS. Тут не стоит лепить одну таблицу notifications.
Лучше сделать так:
Command side: Сервис получает команду SendNotification. Он сохраняет факт отправки в событийном хранилище (или просто пишет в очередь) и дёргает внешний Email-провайдер.
Query side: Отдельная таблица notifications_view, которая заполняется асинхронно из событий. Там уже лежат готовые строки для отображения: Тема, Дата, Статус. Никаких джойнов, никакой бизнес-логики.
Детализируем сценарий: User Story с CQRS-подходом
Теперь давайте спустимся на уровень конкретной задачи. Возьмём историю «Обработка кредитной заявки».
Вместо того чтобы писать простыню ТЗ «Система должна обрабатывать заявки», мы разбиваем её на две User Story, потому что у них разные контексты и разная архитектура.
User Story: Создание заявки (Command Side)
Как клиент,
Я хочу подать заявку на кредит,
Чтобы получить деньги.
Критерии приёмки (AC):
AC1. Успешное создание заявки с валидными данными
Дано: Я заполнил все поля формы (паспорт, сумма, срок).
Когда: Я нажимаю «Отправить заявку».
Тогда: Система проверяет уникальность заявки (нет ли уже такой в обработке), валидирует паспорт, сохраняет агрегат LoanApplication в статусе PENDING.
И: Публикуется событие LoanApplicationCreated.
AC2. Скоринг не пройден
Дано: Внешний сервис скоринга вернул отказ.
Когда: Обработчик команды получает ответ.
Тогда: Статус агрегата меняется на REJECTED, публикуется событие LoanApplicationRejected.
Здесь мы видим, что команда работает только с доменной моделью, проверяет инварианты и порождает события.
User Story: Отображение списка заявок (Query Side)
Как менеджер кредитного отдела,
Я хочу видеть список всех заявок с фильтром по статусу и дате,
Чтобы быстро брать их в работу.
Критерии приёмки (АС):
AC1. Просмотр списка с фильтром
Дано: В системе есть заявки с разными статусами.
Когда: Я захожу на страницу /manager/applications и выбираю фильтр PENDING.
Тогда: Мне возвращается таблица из 10 строк (пагинация), где каждая строка содержит: Номер заявки, ФИО клиента, Сумма, Дата подачи, Статус.
И: Запрос выполняется быстрее 200 мс.
Обратите внимание: в критериях для Query Side нет никакой бизнес-логики. Только данные для отображения. Мы можем позволить себе хранить ФИО клиента прямо в таблице заявок, хотя в нормализованной базе это было бы нарушением. Но здесь это feature.
Проектируем асинхронное взаимодействие: Eventual Consistency — наш выбор
Самая частая претензия к CQRS — «Это ж данные не сразу согласованы!». Да, это так. Но в 90% случаев бизнесу всё равно, увидит ли менеджер новую заявку через 500 миллисекунд или через 2 секунды. Это называется eventual consistency (согласованность в конечном счёте). И это наша плата за масштабирование.
Вот где случаются настоящие попадания в просак, если не продумать этот момент.
Еще пример — вы делаете интеграцию с CRM, где нужно синхронизировать статусы заявок. Если вы кладете событие в очередь RabbitMQ, потребитель на стороне CRM обновляет свою read model. И всё работает, пока очередь не встают колом. CRM падает, сообщения начинают копиться.
И встает вопрос: «А что делать с данными, которые не синхронизировались? Мы не задали его вовремя!»
Чтобы избежать таких проблем, следует нарисовать Sequence диаграммы для каждого критичного сценария. Посмотрите на рисунок 2 — это то, как мы проектируем асинхронную синхронизацию read модели после команды.

Обратите внимание на жирные точки:
Команда сохраняет агрегат и публикует событие в одной транзакции (Outbox pattern, но об этом позже).
Событие попадает в брокер.
Проектор (Projector) слушает события и обновляет read модель.
Read модель теперь готова к использованию, но с небольшой задержкой.
Ключевой момент здесь — обработка ошибок на шаге 3. Что делать, если проектор упал и не смог обновить read модель? Мы добавляем механизм повторных попыток (retry) и dead letter queue. Если и это не помогло — включается ручной режим через админку (перепроецирование событий). Это и есть та самая "нефункционалка", которая отделяет игрушечный проект от боевого.
Как мы храним историю: Event Sourcing как идеальный партнёр
CQRS часто идёт рука об руку с Event Sourcing (ES). Если совсем коротко: Event Sourcing — это когда состояние системы — это побочный эффект, а основной источник правды — это события.
Долго не решался на ES, потому что это непривычно. Но когда вам понадобится полный аудит действий по кредитной заявке (кто, когда и почему изменил статус), то начинаешь понимать — это оно!
Вместо того чтобы хранить текущее состояние заявки, мы храним все события, которые с ней произошли:
LoanApplicationCreated
ClientDataUpdated
LoanApplicationApprovedByManager
LoanApplicationIssued
Текущее состояние (статус, сумма, дата) мы получаем, просто последовательно применив все эти события к пустому агрегату. Это называется «восстановление агрегата».
Это даёт мощнейшую вещь — полную воспроизводимость состояния на любой момент времени. Хотите посмотреть, как выглядела заявка до того, как пришёл скоринг? Без проблем. Надо расследовать инцидент, почему заявка одобрена мошеннику? Просто читаем события.
Код? Да, немного
Чтобы не быть голословным, покажу, как это выглядит в коде на Java. Вот как выглядит команда и обработчик команды:
// Команда public class CreateLoanApplicationCommand { private final String clientId; private final BigDecimal amount; private final int termMonths; // getters, constructor } // Агрегат (это часть доменной модели) @Aggregate public class LoanApplication { @AggregateId private UUID id; private LoanStatus status; private ClientData clientData; public LoanApplication() { } public LoanApplication(CreateLoanApplicationCommand cmd) { // Проверка бизнес-правил if (cmd.getAmount().compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("Sum must be positive"); } // Публикация события apply(new LoanApplicationCreatedEvent( UUID.randomUUID(), cmd.getClientId(), cmd.getAmount(), cmd.getTermMonths() )); } public void on(LoanApplicationCreatedEvent event) { this.id = event.getAggregateId(); this.status = LoanStatus.PENDING; this.clientData = new ClientData(event.getClientId()); // ... инициализация (и все, что нам потребуется...) } } // Обработчик команды (Spring @Component) @Component public class LoanApplicationCommandHandler { @Autowired private EventSourcingRepository<LoanApplication> repository; public void handle(CreateLoanApplicationCommand command) { LoanApplication aggregate = new LoanApplication(command); repository.save(aggregate); } }
А вот как выглядит проектор, который обновляет read модель для UI менеджера:
@Component public class LoanApplicationProjector { @Autowired private JdbcTemplate jdbcTemplate; @EventHandler public void on(LoanApplicationCreatedEvent event) { String sql = "INSERT INTO loan_application_view " + "(id, client_id, amount, term_months, status, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)"; jdbcTemplate.update(sql, event.getAggregateId(), event.getClientId(), event.getAmount(), event.getTermMonths(), "PENDING", event.getTimestamp() ); } @EventHandler public void on(LoanApplicationApprovedEvent event) { String sql = "UPDATE loan_application_view SET status = ? WHERE id = ?"; jdbcTemplate.update(sql, "APPROVED", event.getAggregateId()); } }
Видите разницу? Код команд проверяет правила и живет в мире домена. Код запросов — это просто примитивное CRUD-хранилище для отображения.
Нефункциональные требования и best practice
Хороший архитектор (и аналитик) спросит про NFR. Для CQRS-системы это критично. И здесь нужно отметить:
Согласованность (Consistency). Мы всегда должны фиксировать компромисс: бизнес готов ждать 2 секунды ради высокой доступности.
Идемпотентность. События могут приходить дважды (особенно в Kafka). Обработчики чтения должны быть идемпотентны.
Outbox Pattern. Как гарантировать, что событие точно отправится, если транзакция с базой прошла? Мы пишем событие в ту же базу, в отдельную таблицу
outbox, а отдельный воркер читает оттуда и публикует в брокер. Это стандарт де-факто.Мониторинг. Нужно метрики по лагу событий. Если лаг между Command и Query моделями начинает расти — значит, проекторы не справляются или умер брокер.
Заключение: CQRS — это про мышление
CQRS — это не просто технология (Axon Framework, Kafka, RabbitMQ). Это способ мышления. Вы начинаете видеть мир не как набор таблиц, а как поток событий и проекции этого потока для разных задач. Системный аналитик в таком мире перестаёт быть просто «писцом», он становится архитектором потоков данных.
Плохой аналитик напишет: «Система должна показывать список заявок». Хороший спросит: «Какие именно заявки, с какой скоростью, для какого экрана, и насколько свежие данные нужны бизнесу?». Ответы на эти вопросы и приведут вас либо к простому SELECT * FROM orders, либо к правильной CQRS-архитектуре.

Если этот разбор заставил вас задуматься о том, как вы проектируете системы, и вы хотите научиться применять предметно-ориентированное проектирование и асинхронные подходы не методом проб и ошибок, приглашаю вас на открытые уроки, где мы будем разбирать это на реальных примерах.
4 марта, 20:00. «Саги против распределённых транзакций: как моделировать рабочие потоки в распределённой архитектуре». Записаться
18 марта, 20:00. «API Gateway (API-шлюз) и не только: шаги к идеальной архитектуре внешних API (интерфейс программирования приложений)». Записаться
Уроки пройдут в рамках курса «Предметно-ориентированное проектирование и асинхронная архитектура» в Отус.
