Уровень зрелости 2: Чтение и запись разделены
Второй уровень Use Case Pattern: команды и запросы разнесены через UseCaseCommand / UseCaseQuery, у запросов своя оптимизированная Read Model.
← назад к методологии · уровень 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 / UseCaseQuery | usecase-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: выделение домена.
Что почитать рядом
- CQRS — разделение чтения и записи как отдельный паттерн, без привязки к UCP.
- Use Case Pattern (методология) — все четыре уровня в одном месте.
- Apache Kafka — если Read Model обновляется через события.
- Распределённые паттерны — Outbox для атомарной публикации событий, Idempotent Consumer для приёма.