Уровень зрелости 2: Чтение и запись разделены

Второй уровень Use Case Pattern: команды и запросы разнесены через UseCaseCommand / UseCaseQuery, у запросов своя оптимизированная Read Model.

Статья внедрена в скилл AI-агента ucp-pattern-review / ucp-pattern-design Эталонная библиотека к статье usecase-pattern Use Case Pattern уровень 2 CQRS

← назад к методологии · уровень 2 из 4 · предыдущий: уровень 1

Какую проблему решает

На стартовом уровне команды (запись) и запросы (чтение) делят одну модель данных. Это работает, пока нагрузки сопоставимы. Когда чтения начинают мешать записи — становится тесно: длинные SELECT блокируют, запросам нужны агрессивные кэши, а записям — строгие транзакции.

Второй уровень разводит их по разным дорогам — это и есть CQRS (Command Query Responsibility Segregation). Команды идут одной веткой, запросы — другой, со своей моделью данных и политиками.

Когда подходит

  • Чтения сильно превышают запись (10:1, 100:1, 1000:1).
  • Запросы требуют агрессивного кэширования или денормализованных витрин.
  • Командам нужны отдельные политики транзакций или изоляции, которые мешают чтению.
  • В UI много экранов с разными представлениями одной и той же сущности — каждое со своими полями.

Что должно быть

Явное разделение по типу операции. Команды реализуют маркер UseCaseCommand — они меняют состояние. Запросы реализуют UseCaseQuery — они только читают. Это видно в типе, и легко проверяется в обзоре.

Read Model — отдельная оптимизированная модель данных для чтения. Запросы не идут к той же таблице, что и команды. Read Model — это отдельное представление: материализованное view, отдельная denormalized-таблица, проекция в Elasticsearch, кэш в Redis. Главное — она оптимизирована под конкретные запросы UI.

Разные политики на handler-ах. Командные handler-ы — @Transactional (read-write). Query-handler-ы — @Transactional(readOnly = true) или вообще без транзакции, плюс @Cacheable. Каждой стороне — свои настройки.

Команды не возвращают «толстые» DTO. Команда возвращает идентификатор созданной сущности, минимальный summary или ничего (UseCaseEmptyResult). Если клиенту нужны полные данные — он вызывает запрос отдельно. Это важно: иначе команды быстро превращаются в гибрид «и пишу, и читаю», и весь смысл CQRS теряется.

Запрос не меняет состояние. Никаких «обновим last-seen в этом read-handler-е» — это уже команда.

Как обновляется Read Model

Read Model не пишется напрямую — она строится из write-side. Варианты:

  • Через события (рекомендуется): write-side публикует событие при изменении состояния, отдельный обработчик обновляет Read Model.
  • Через CDC (Change Data Capture): инструменты вроде Debezium читают журнал транзакций БД и публикуют изменения как события.
  • Через материализованные view: PostgreSQL умеет, и для простых случаев этого достаточно.
  • Лениво (rebuild по запросу): простейший вариант — пересчитать Read Model при первом запросе после кэш-инвалидации.

Все эти варианты дают eventual consistency: запрос может вернуть слегка устаревшие данные. Это допустимая цена за скорость чтения и важно явно зафиксировать в спеке: «UI видит изменения с задержкой до N секунд».

Что НЕ нужно делать на этом уровне

  • Заводить агрегаты и доменные события — это уровень 3.
  • Строить порты и адаптеры — это уровень 4.
  • Использовать Event Sourcing — это отдельная сложность, не путать с CQRS.

CQRS и Event Sourcing — разные вещи. CQRS можно делать без него. Большинству сервисов оно и не нужно.

Библиотеки и инструменты

ЧтоЧем
Маркеры UseCaseCommand / UseCaseQueryusecase-pattern-starter (пакет ru.vikulinva.usecase.cqrs)
Read Model в БДjOOQ + материализованные view PostgreSQL
Read Model в кэшеRedis + Spring Cache
Read Model в полнотекстовом поискеElasticsearch + проекции из событий
Маппинг между моделямиMapStruct
МетрикиMicrometer (теги usecase.kind=command|query)

К библиотекам уровня 1 добавляются: Redis (если нужен), Elasticsearch (если нужен), MapStruct (для маппинга разных моделей).

Признаки, что пора уходить на уровень 3

  • Бизнес-правил много, и они «расползаются» по handler-ам — приходится копипастить проверки.
  • В команде заводят разные термины для одного и того же — нужен общий язык домена.
  • Появляются явные инварианты: «нельзя оплатить заказ, который не подтверждён», «остаток не может быть отрицательным» — и хочется, чтобы это жило в одном месте, а не разбросано по проверкам.
  • Отдельные модули логически самодостаточны — намечаются Bounded Context-ы.

Тогда — Уровень 3: выделение домена.

Что почитать рядом