CQRS — не бинарное «применяем или нет». Это шкала: начинаете с минимального разделения, добавляете инфраструктуру только тогда, когда боль становится настоящей. Чрезмерная CQRS-инфраструктура на маленьком сервисе обходится дороже, чем постепенный рост.
В этой статье — четыре уровня зрелости, типичный путь эволюции и частые ошибки.
Сразу про нумерацию, чтобы потом не путаться. Уровней четыре, а номеров три: третий распадается на две ступени — 3-split (разделены интерфейсы, база одна) и 3-event-driven (разделены хранилища). Дальше по тексту «Уровень 3» без уточнения не встречается: каждый раз указано, какая из двух ступеней имеется в виду.
Четыре ступени подряд: каждая добавляет ровно одну вещь, и только последний переход добавляет инфраструктуру, а не файлы.
Уровень 1 — CQRS не применяется
Классический Spring-сервис: один @Service, общий @Transactional, методы чтения и записи в одном классе. Никаких специальных маркеров, никаких отдельных репозиториев.
@Service
public class OrderService {
@Transactional
public OrderDto createOrder(CreateOrderRequest req) { ... }
@Transactional(readOnly = true)
public OrderDto getOrder(Long id) { ... }
}
Это нормально для утилитных микросервисов, CRUD-прокси и небольших вспомогательных сервисов. Одна модель данных, простая структура — Spring Data покрывает всё.
Добавлять CQRS-маркеры сюда без реального разделения чтения и записи — формализм без пользы.
Уровень 2 — lightweight-маркеры
Появляется, когда сервис обретает реальный бизнес-домен: Use Case Pattern, отдельные команды и запросы, хендлеры. Читать и писать по-прежнему идут через один репозиторий, но разделение теперь формальное и соблюдается явно.
Каждый use-case реализует маркерный интерфейс: UseCaseCommand — для команды, UseCaseQuery — для запроса. Это не просто соглашение — маркеры меняют поведение, и нарушение ловится автоматически:
@Transactional(readOnly = true)на query-handler-е — не подсказка, а ограничение. Hibernate при нём не сверяет объекты на изменения, а на голом JDBC транзакция открывается как READ ONLY, и попытка записи в ней падает с ошибкой. Без этого флага маркерUseCaseQueryни на что не влияет.SelectMode.NO_LOCKна операциях чтения. ОбычныйSELECTв PostgreSQL строк и так не блокирует, так что сам по себе он ничего не снимает.NO_LOCK— это явный отказ добавлятьFOR UPDATE: запрос идёт без блокировки не по забывчивости, а потому что так решили, и в коде это видно.- Раздельные метрики для команд и запросов.
public record CreateOrderCommand(...) implements UseCaseCommand<OrderId> {}
public record GetOrderQuery(Long id) implements UseCaseQuery<OrderSummary> {}
@Component
@RequiredArgsConstructor
class CreateOrderHandler implements UseCaseHandler<CreateOrderCommand, OrderId> {
private final OrderRepository orderRepository;
private final OrderFactory orderFactory;
@Override
@Transactional
public OrderId handle(CreateOrderCommand cmd) {
Order order = orderFactory.createFor(cmd.customerId(), cmd.items());
orderRepository.save(order);
return order.id();
}
}
@Component
@RequiredArgsConstructor
class GetOrderHandler implements UseCaseHandler<GetOrderQuery, OrderSummary> {
private final OrderRepository orderRepository;
private final OrderSummaryMapper orderSummaryMapper;
@Override
@Transactional(readOnly = true)
public OrderSummary handle(GetOrderQuery query) {
Order order = orderRepository.findById(query.id(), SelectMode.NO_LOCK)
.orElseThrow(...);
return orderSummaryMapper.toSummary(order);
}
}
Оба хендлера используют один и тот же OrderRepository. Разделять интерфейсы пока рано — выгоды от этого шага на этом уровне не будет.
Обратите внимание, что запрос возвращает OrderSummary, а не OrderJson. JSON — это форма ответа HTTP, и рождаться она должна в адаптере, который этот HTTP обслуживает. Ядро отдаёт наружу свой плоский record, а превращает его в JSON контроллер. Иначе через возвращаемый тип в ядро заезжает Jackson со всеми своими аннотациями — и вместе с ним требование не ломать формат ответа при изменении домена.
Уровень 3 split — отдельный ViewRepository
Со временем read-сторона начинает отличаться от write. UI просит проекции с полями из нескольких таблиц, аналитики хотят сводки — а агрегат спроектирован под запись, не под отображение. Запрашивать OrderSummary через OrderRepository становится неудобно.
Решение — два отдельных интерфейса в домене:
// core/domain/port/out/OrderRepository.java
public interface OrderRepository {
Optional<Order> findById(OrderId id, SelectMode mode);
void save(Order order);
}
// core/domain/port/out/OrderViewRepository.java
public interface OrderViewRepository {
Optional<OrderSummary> findSummaryById(Long orderId);
Page<OrderSummary> search(Long customerId, OrderStatus status, Pageable p);
}
OrderRepository возвращает агрегат и используется в command-handler-ах. OrderViewRepository возвращает read-DTO и используется в query-handler-ах. Никакого пересечения.
Read-DTO (OrderSummary) — самостоятельные record-ы. Их структура подчинена тому, что нужно API и UI, а не внутреннему устройству агрегата.
Физически данные по-прежнему в одном PostgreSQL — разделение пока только на уровне типов и интерфейсов, не инфраструктуры.
А откуда репозиторий чтения берёт данные — вопрос, который здесь решается и который делит этот уровень на две половины. Вариантов два, и они разной цены:
Из тех же нормализованных таблиц, запросом с объединениями. Реализация репозитория чтения делает один запрос по существующим таблицам и сразу собирает плоскую структуру ответа — без загрузки агрегата и без лишних объектов. Это половина выигрыша почти бесплатно: запрос ровно под нужные поля, никакого дублирования данных, никакой синхронизации. С этого и начинают, и для большинства сервисов этого достаточно.
Из отдельной денормализованной таблицы в той же базе. Появляется, когда запрос с объединениями перестаёт укладываться в требования: пять и больше таблиц, нестабильное время на 95-м процентиле, тяжёлые группировки. Тогда рядом заводят витрину и обновляют её в той же транзакции, что запись, — и это ровно то, что соседняя статья называет своей второй ступенью. Разбор, включая цену, — в статье про уровни CQRS.
То есть внутри «уровня 3 split» есть две ступени: сначала свои запросы по общим таблицам, потом своя таблица. Между вторым из них и event-driven разница уже только в том, где лежит витрина и как она обновляется, — и именно поэтому переход к событиям чаще всего оказывается не нужен: денормализованная таблица в той же базе закрывает и форму данных, и скорость, не принося отложенности.
Цена каждого уровня
Цена названа только у последнего уровня, а она есть у каждого — и решение об уровне принимают по ней.
| Уровень | Что добавляется в код | Что добавляется в эксплуатацию |
|---|---|---|
| 1 (нет CQRS) | — | — |
| 2 (маркеры) | два типа-маркера, диспетчер, отдельные настройки транзакций; каждая операция становится парой «запрос — обработчик» вместо метода сервиса | ничего |
| 3 split, запросы по общим таблицам | второй интерфейс репозитория и его реализация, структуры ответа под каждый класс запросов, своё преобразование | ничего |
| 3 split, своя таблица | плюс обновление витрины в транзакции записи, скрипт перестроения, миграции витрины | миграции и сверка |
| 3 event-driven | плюс таблица исходящих, отправщик, потребитель, идемпотентность, версии | брокер, отставание, очередь недоставленных, перестроение, сверка, второе хранилище |
Что здесь важно заметить. Цена второго уровня — не ноль: на каждую операцию появляется отдельный тип и отдельный обработчик, то есть два файла вместо метода в сервисе. Для сервиса на двадцать операций это сорок файлов, и это осознанная плата за то, что чтение и запись различает компилятор, а метрики и настройки транзакций разделены. Цена split — рост числа структур ответа и дублирование преобразования: одна и та же сущность появляется в трёх-четырёх видах, и каждый нужно поддерживать при изменении полей.
И то, что цена растёт не линейно: от первого ко второму и третьему split — единицы и десятки файлов, от split к event-driven — новая инфраструктура и постоянное внимание. Именно поэтому граница между split и event-driven — главная в этой шкале, а все остальные переходы дешёвые.
Частая ошибка: добавить методы вроде findSummary() прямо в OrderRepository. Так OrderRepository будет расти вместе с каждой новой функциональностью UI, смешивая write-API с read-API в одном интерфейсе.
Уровень 3 event-driven — отдельное хранилище
Следующий шаг нужен, когда read-нагрузка начинает мешать write-стороне, или когда паттерн чтения фундаментально отличается — например, нужен полнотекстовый поиск или аналитические сводки по миллионам записей.
Read-model переезжает в отдельную таблицу, Redis или Elasticsearch. Синхронизация идёт через outbox и Kafka:
Запись и чтение разъезжаются по разным хранилищам, а между ними — надёжная доставка: событие ложится в outbox той же транзакцией, что и заказ, и только потом уезжает в брокер. Проекция отстаёт на секунды, и с этим отставанием нужно согласиться продукту.
Что добавляется к предыдущему уровню:
- Outbox-таблица в write-БД. Запись в outbox происходит в той же транзакции, что и запись агрегата.
- Outbox-relay — фоновый процесс, который публикует события в Kafka.
- Read-side consumer — обновляет read-model по событиям. Idempotency обязательна: одно событие может прийти дважды.
- Bootstrap-процедура для первоначального заполнения и восстановления read-model после сбоя.
Цена этого уровня:
- Eventual consistency: данные в read-model появляются с задержкой (100 мс — 1 с в норме, больше при нагрузке или сбоях).
- Новые точки отказа: зависший consumer, накопившаяся очередь в outbox, рассинхронизация.
- Дополнительный мониторинг: lag consumer, здоровье outbox-relay.
Если нагрузка на чтение ещё не критична, часто дешевле обойтись read-репликой PostgreSQL и кешем.
Эволюция всегда снизу вверх
Путь по уровням строго однонаправленный: 1 → 2 → 3-split → 3-event-driven. Каждый переход должен быть обоснован метриками или новыми требованиями, а не желанием использовать «правильную архитектуру».
Типичный жизненный путь сервиса:
- Стартовал как CRUD-прокси — Уровень 1.
- Появился реальный домен, ввели Use Case Pattern — Уровень 2 с маркерами.
- UI начал просить проекции, не совпадающие с агрегатом — Уровень 3 split с
OrderViewRepository. - p95 latency чтения пробил SLA или понадобился полнотекстовый поиск — Уровень 3 event-driven.
Измеримые пороги перехода
«Пробил SLA» — формулировка без чисел, а решение стоит принимать по измеренному. Пороги, которые стоит записать для своего сервиса (числа ниже — ориентиры, а не норматив):
С 1 на 2 — не по метрикам, а по факту: появились операции, меняющие состояние по бизнес-правилам (а не просто сохраняющие форму). Порог примерно такой: больше пяти операций записи с правилами — пора различать команды и запросы. Стоит это ничего, поэтому порог низкий.
С 2 на 3 split (запросы по общим таблицам) — когда структура ответа перестаёт совпадать с агрегатом: интерфейсу нужны поля из трёх и более сущностей в одном ответе, или ответ требует меньше половины полей агрегата. Признак в коде: обработчик запроса загружает агрегат и берёт из него три поля.
С 3 split на свою таблицу — по измеренному времени: время ответа на 95-м процентиле выше вашей планки (для интерфейса обычно 200–500 мс на запрос данных экрана) при запросе, объединяющем пять и больше таблиц, и это подтверждено планом запроса (неверные оценки, чтение таблиц вместо индексов). Плюс объём: витрина в той же базе оправдана начиная с сотен тысяч строк — на десяти тысячах разницы не будет.
С 3 split на event-driven — три независимых порога, любой из которых достаточен:
- Отношение чтений к записям 10:1 и выше при том, что чтения уже мешают: доля времени базы на чтение больше половины, и она растёт.
- Форма запросов недостижима в реляционной базе: полнотекстовый поиск с ранжированием по десяткам миллионов документов, двадцать фильтров в произвольных сочетаниях, агрегаты по миллиардам строк.
- Чтение мешает записи измеримо: выросло время записи, растут ожидания блокировок, реплика уже введена и не помогает.
И порог, при котором переход НЕ нужен, хотя кажется, что нужен: время ответа плохое, но запрос без индекса; объём растёт, но таблица не секционирована; отчёты тяжёлые, но их можно унести на реплику. Проверьте это до того, как заводить брокер, — перечень приёмов в статье про уровни.
Как измерять, чтобы порогам можно было верить. Время ответа — по перцентилям на стороне обработчика запроса (не среднее и не на клиенте); доля времени базы — из статистики запросов; отношение чтений к записям — по числу вызовов обработчиков, а не по интуиции. Без этих трёх чисел разговор об уровне превращается в спор о вкусах, и решение принимает тот, кто громче.
Возврат назад случается редко и обычно означает, что начали слишком высоко. Если слили два сервиса в один и event-driven read-model потеряла смысл — упрощают до split или до Уровня 2.
Это дорогая операция, и у неё есть порядок — обратный развёртыванию, с обратимым каждым шагом:
- Переключить чтение на сторону записи или на запросы по общим таблицам — по одному классу запросов, за флагом, сравнивая время ответа и результаты.
- Убедиться, что читателей витрины не осталось: метрика обращений по каждому запросу, неделя с нулём.
- Найти чужих подписчиков событий — самый частый сюрприз. За год на ваш поток могли подписаться другие сервисы; их находят по группам потребителей в брокере и договариваются, а не отключают. Если события нужны соседям, таблица исходящих и поток остаются, а уходит только ваша проекция.
- Остановить своего потребителя, оставив витрину: обратимо, включили обратно — догнал.
- Удалить витрину и хранилище — последним, с резервной копией, после пары недель работы без неё.
- Убрать код, настройки, мониторинг и права.
Что при упрощении не выбрасывают: разделение команд и запросов в коде. Оно ничего не стоит и полезно само; упрощение означает отказ от отдельного хранилища и потребителей, а не возврат к одному сервису на всё.
Где написано, на каком уровне сервис
Шкала бесполезна, если уровень существует только в голове автора: половина кода тихо уезжает на третий уровень, половина остаётся на первом, и через год непонятно, где мы. Механизм фиксации простой и состоит из трёх частей.
1. Уровень объявлен в репозитории. Одна строка в файле с описанием сервиса (README или карточка сервиса):
CQRS: уровень 3-split (запросы по общим таблицам).
Витрины нет, событий наружу нет. Решение: ADR-014.
Плюс запись решения о переходе — в формате ADR, где сказано, почему перешли и по каким числам. Тогда через год виден не только уровень, но и основания.
2. Уровень проверяется машиной. Ровно то, что не даёт коду уехать: правила зависимостей, выраженные тестом архитектуры. Примеры проверок по уровням:
- На любом уровне выше первого: обработчик запроса не зависит от репозитория записи, обработчик команды не зависит от репозитория чтения.
- На split: в интерфейсе репозитория записи нет методов, возвращающих структуры ответа (проверяется по типам возвращаемых значений); в интерфейсе репозитория чтения нет методов записи.
- Если event-driven не введён: в коде нет обращений к таблице исходящих и нет потребителей событий проекции. Такой запрет — самый полезный, потому что именно он останавливает «а я тут на всякий случай завёл».
- Если введён: проекция обновляется только потребителем событий, а не из обработчиков команд (проверяется по тому, кто вызывает репозиторий витрины).
Как это писать — в статье про тесты архитектуры. Ценность здесь не в красоте: правило, которое не проверяется, через полгода нарушено, и на ревью это не отследить, потому что каждое отдельное нарушение выглядит разумным.
3. У уровня есть владелец. Человек, который отвечает на вопрос «мы всё ещё на split?» и к которому идут с предложением перейти. Обычно техлид сервиса; без имени решение о переходе принимается тем, кто первым завёл брокер.
Переход между уровнями во времени
Последний практический вопрос: это одна задача, спринт или несколько выпусков — и можно ли жить в промежуточном состоянии.
Переход с 1 на 2 — одна задача на несколько дней, и он делается сразу целиком для новых операций и постепенно для старых. Смешанное состояние («половина операций — команды, половина — методы сервиса») терпимо и нормально живёт месяцами: это не два способа делать одно и то же, а просто не все переписали. Ориентир: новые операции — по новым правилам, старые переписываются, когда к ним приходит задача.
Переход на split — тоже постепенный и по классам запросов: завели репозиторий чтения, перевели на него один запрос, потом второй. Смешанное состояние безвредно, потому что физически данные одни и те же: часть запросов читает через репозиторий записи, часть — через новый.
Переход на свою таблицу — одна задача с известными шагами: миграция, обновление в обработчиках, перестроение, переключение чтения за флагом. Ориентир: спринт, и он должен быть закончен. Долго жить в состоянии «витрина есть, но обновляется не всеми обработчиками» нельзя: это гарантированное расхождение.
Переход на event-driven — несколько выпусков, и порядок обязателен:
- Таблица исходящих и отправщик, но проекция ещё обновляется как раньше (событие публикуется, его никто не читает). Выкатили, убедились, что отправщик работает и не отстаёт.
- Потребитель, который пишет в новую витрину, параллельно старой. Витрина наполняется, чтение ещё идёт по-старому.
- Перестроение новой витрины, сверка со старой.
- Переключение чтения за флагом, по одному классу запросов.
- Удаление старого пути обновления.
Промежуточное состояние здесь опасно, и это отличает этот переход от остальных: если чтение уже переключено, а потребитель отстаёт или падает, пользователи видят неверные данные. Поэтому шаги 1–3 можно растянуть на выпуски, а шаг 4 делается только когда есть метрики отставания и сверка — то есть готовность к эксплуатации появляется до переключения, а не после.
Общее правило: дешёвые переходы делают постепенно и живут в смешанном состоянии спокойно; дорогой переход делают по шагам с проверкой на каждом и не оставляют незаконченным.
Частые ошибки
Маркеры, которые ничего не меняют. Добавить implements UseCaseCommand к команде на Уровне 1, при этом вызывать её напрямую через @Service, без readOnly = true на запросах — это пустое оформление. Маркер должен что-то менять в поведении, иначе он лишний.
Read-методы в write-репозитории на Уровне 3-split. Если выделен OrderViewRepository, но запросы вроде findSummary() остаются в OrderRepository — смысл разделения теряется. OrderRepository будет расти вместе с UI, смешивая ответственности.
Прыжок с Уровня 1 сразу на event-driven. Outbox, Kafka, отдельная read-таблица — это инфраструктура с нетривиальной ценой. Без реальной нагрузки она создаёт сложность, но не решает проблему.
Глубже: реплика и кэш как дешёвая проекция: то же отставание, только неуправляемоерасширенное
Обе статьи про уровни называют чтение с реплики базы и кэш дешёвой альтернативой отдельному хранилищу, и это правда, но с оговоркой, из-за которой команды удивляются тем же симптомам, от которых бежали.
Реплика отстаёт. Запись прошла на основной, реплика получит её через миллисекунды или секунды, и запрос, ушедший на реплику сразу после команды, не увидит своей записи: та же отложенная согласованность, что у проекции через события, только без инструментов. У проекции есть aggregateVersion и правило «показать пользователю его запись сразу» из статьи про синхронизацию; у реплики этого нет, и «прочитать своё» решают маршрутизацией: чтение того же пользователя в первые секунды после его команды идёт на основной, остальное на реплику. Отставание реплики к тому же неуправляемое: при нагрузке или долгом запросе на реплике оно растёт до минут, и метрику отставания смотрят так же, как lag потребителя.
Кэш это та же история с явным сроком: значение в кэше устаревает на срок жизни или до инвалидации, инвалидация по событию это по сути проекция, только в память, и она так же отстаёт и так же теряет события. Кэш не заменяет проекцию по второй причине: он хранит ответы на конкретные ключи, а проекция это таблица под запросы с фильтрами и сортировкой, которую кэшем не собрать.
Когда это приемлемо. Списки и отчёты, где секунда отставания не видна пользователю и не влияет на решение; каталог, справочники, витрины. Когда нет: экран, куда пользователь возвращается сразу после своей команды, и любые проверки перед изменением (остаток, лимит), которые читают только с основного. Правило для уровня со split-репозиторием: у ViewRepository явный признак, можно ли его читать с реплики, а не общий переключатель на все чтения, потому что «все чтения на реплику» ломает ровно те экраны, что заметны.
Коротко
- CQRS — шкала зрелости, не бинарное решение. Берёте ровно столько, сколько нужно сейчас. Уровень 1: классический Spring-сервис без разделения. Нормально для простых сервисов.
- Уровень 2: маркеры
UseCaseCommand/UseCaseQuery, один репозиторий,readOnly = trueна query-handler-ах. Уровень 3 split: два интерфейса —OrderRepository(агрегат, write) иOrderViewRepository(read-DTO). Одна БД. - Уровень 3 event-driven: отдельное хранилище для read-model, синхронизация через outbox + Kafka. Eventual consistency. Каждый переход — по метрикам и реальной боли, не по моде.
- Маркеры, которые ничего не меняют, — формализм. Не добавляйте их, если нечего обеспечивать.
- Реплика и кэш дают ту же отложенную согласованность, что проекция, но без версии и без правила «показать своё»: чтение после своей команды идёт на основной, отставание реплики меряют, признак «можно с реплики» ставят на конкретный запрос, а не на все.
- Внутри split две ступени: сначала свои запросы по общим таблицам (почти бесплатно), потом своя денормализованная таблица в той же базе — и она часто снимает саму потребность в событиях.
- Цена есть у каждого уровня: на втором — тип и обработчик на каждую операцию, на split — рост числа структур ответа, и только между split и event-driven цена прыгает на инфраструктуру.
- Пороги измеримые: пять операций с правилами для второго, поля из трёх сущностей для split, 95-й процентиль выше планки при пяти объединениях для своей таблицы, отношение 10:1 или недостижимая форма запросов для событий.
- Уровень фиксируется тремя вещами: строкой в описании сервиса с записью решения, тестами архитектуры (включая запрет того, что на этом уровне не положено) и владельцем с именем.
- Дешёвые переходы делают постепенно и живут в смешанном состоянии; переход на события идёт выпусками в строгом порядке, и чтение переключают только когда готовы метрики отставания и сверка.
Что почитать дальше
- Command side — write-handler на Уровне 2 и выше.
- Query side — read-handler с
ViewRepository. - Read-model — где хранить отдельную проекцию на Уровне 3 event-driven.
- Sync via events — outbox и Kafka для синхронизации.
- Когда CQRS оправдан — пороги перехода между уровнями.