← назад к разделу

CQRS — не бинарное «применяем или нет». Это шкала: начинаете с минимального разделения, добавляете инфраструктуру только тогда, когда боль становится настоящей. Чрезмерная CQRS-инфраструктура на маленьком сервисе обходится дороже, чем постепенный рост.

В этой статье — четыре уровня зрелости, типичный путь эволюции и частые ошибки.

Сразу про нумерацию, чтобы потом не путаться. Уровней четыре, а номеров три: третий распадается на две ступени — 3-split (разделены интерфейсы, база одна) и 3-event-driven (разделены хранилища). Дальше по тексту «Уровень 3» без уточнения не встречается: каждый раз указано, какая из двух ступеней имеется в виду.

Уровень 1 без разделения Уровень 2 маркеры команд Уровень 3-split второй репозиторий Уровень 3-event-driven своё хранилище

Четыре ступени подряд: каждая добавляет ровно одну вещь, и только последний переход добавляет инфраструктуру, а не файлы.

Обязательно

Уровень 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:

PostgreSQL order и outbox — одной транзакцией outbox-relay Kafka: order.events read-side consumer order_summary денормализованная таблица под запросы

Запись и чтение разъезжаются по разным хранилищам, а между ними — надёжная доставка: событие ложится в 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. Каждый переход должен быть обоснован метриками или новыми требованиями, а не желанием использовать «правильную архитектуру».

Типичный жизненный путь сервиса:

  1. Стартовал как CRUD-прокси — Уровень 1.
  2. Появился реальный домен, ввели Use Case Pattern — Уровень 2 с маркерами.
  3. UI начал просить проекции, не совпадающие с агрегатом — Уровень 3 split с OrderViewRepository.
  4. 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. Переключить чтение на сторону записи или на запросы по общим таблицам — по одному классу запросов, за флагом, сравнивая время ответа и результаты.
  2. Убедиться, что читателей витрины не осталось: метрика обращений по каждому запросу, неделя с нулём.
  3. Найти чужих подписчиков событий — самый частый сюрприз. За год на ваш поток могли подписаться другие сервисы; их находят по группам потребителей в брокере и договариваются, а не отключают. Если события нужны соседям, таблица исходящих и поток остаются, а уходит только ваша проекция.
  4. Остановить своего потребителя, оставив витрину: обратимо, включили обратно — догнал.
  5. Удалить витрину и хранилище — последним, с резервной копией, после пары недель работы без неё.
  6. Убрать код, настройки, мониторинг и права.

Что при упрощении не выбрасывают: разделение команд и запросов в коде. Оно ничего не стоит и полезно само; упрощение означает отказ от отдельного хранилища и потребителей, а не возврат к одному сервису на всё.

Где написано, на каком уровне сервис

Шкала бесполезна, если уровень существует только в голове автора: половина кода тихо уезжает на третий уровень, половина остаётся на первом, и через год непонятно, где мы. Механизм фиксации простой и состоит из трёх частей.

1. Уровень объявлен в репозитории. Одна строка в файле с описанием сервиса (README или карточка сервиса):

CQRS: уровень 3-split (запросы по общим таблицам).
Витрины нет, событий наружу нет. Решение: ADR-014.

Плюс запись решения о переходе — в формате ADR, где сказано, почему перешли и по каким числам. Тогда через год виден не только уровень, но и основания.

2. Уровень проверяется машиной. Ровно то, что не даёт коду уехать: правила зависимостей, выраженные тестом архитектуры. Примеры проверок по уровням:

  • На любом уровне выше первого: обработчик запроса не зависит от репозитория записи, обработчик команды не зависит от репозитория чтения.
  • На split: в интерфейсе репозитория записи нет методов, возвращающих структуры ответа (проверяется по типам возвращаемых значений); в интерфейсе репозитория чтения нет методов записи.
  • Если event-driven не введён: в коде нет обращений к таблице исходящих и нет потребителей событий проекции. Такой запрет — самый полезный, потому что именно он останавливает «а я тут на всякий случай завёл».
  • Если введён: проекция обновляется только потребителем событий, а не из обработчиков команд (проверяется по тому, кто вызывает репозиторий витрины).

Как это писать — в статье про тесты архитектуры. Ценность здесь не в красоте: правило, которое не проверяется, через полгода нарушено, и на ревью это не отследить, потому что каждое отдельное нарушение выглядит разумным.

3. У уровня есть владелец. Человек, который отвечает на вопрос «мы всё ещё на split?» и к которому идут с предложением перейти. Обычно техлид сервиса; без имени решение о переходе принимается тем, кто первым завёл брокер.

Переход между уровнями во времени

Последний практический вопрос: это одна задача, спринт или несколько выпусков — и можно ли жить в промежуточном состоянии.

Переход с 1 на 2 — одна задача на несколько дней, и он делается сразу целиком для новых операций и постепенно для старых. Смешанное состояние («половина операций — команды, половина — методы сервиса») терпимо и нормально живёт месяцами: это не два способа делать одно и то же, а просто не все переписали. Ориентир: новые операции — по новым правилам, старые переписываются, когда к ним приходит задача.

Переход на split — тоже постепенный и по классам запросов: завели репозиторий чтения, перевели на него один запрос, потом второй. Смешанное состояние безвредно, потому что физически данные одни и те же: часть запросов читает через репозиторий записи, часть — через новый.

Переход на свою таблицу — одна задача с известными шагами: миграция, обновление в обработчиках, перестроение, переключение чтения за флагом. Ориентир: спринт, и он должен быть закончен. Долго жить в состоянии «витрина есть, но обновляется не всеми обработчиками» нельзя: это гарантированное расхождение.

Переход на event-driven — несколько выпусков, и порядок обязателен:

  1. Таблица исходящих и отправщик, но проекция ещё обновляется как раньше (событие публикуется, его никто не читает). Выкатили, убедились, что отправщик работает и не отстаёт.
  2. Потребитель, который пишет в новую витрину, параллельно старой. Витрина наполняется, чтение ещё идёт по-старому.
  3. Перестроение новой витрины, сверка со старой.
  4. Переключение чтения за флагом, по одному классу запросов.
  5. Удаление старого пути обновления.

Промежуточное состояние здесь опасно, и это отличает этот переход от остальных: если чтение уже переключено, а потребитель отстаёт или падает, пользователи видят неверные данные. Поэтому шаги 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 или недостижимая форма запросов для событий.
  • Уровень фиксируется тремя вещами: строкой в описании сервиса с записью решения, тестами архитектуры (включая запрет того, что на этом уровне не положено) и владельцем с именем.
  • Дешёвые переходы делают постепенно и живут в смешанном состоянии; переход на события идёт выпусками в строгом порядке, и чтение переключают только когда готовы метрики отставания и сверка.

Что почитать дальше