Опирается на правила:
R-CQRS-TIER-1…R-CQRS-TIER-5иR-CQRS-TIER-X1…R-CQRS-TIER-X2из CQRS Style Guide → раздел 6. Уровень и эволюция.
Важно знать
- Уровень 1 (3-tier-layered): CQRS не применяется. UseCase + Handler без маркеров.
- Уровень 2 (Use Case Pattern): lightweight CQRS — маркеры
UseCaseCommand/UseCaseQueryобязательны. Один<X>Repository, read черезSelectMode.NO_LOCK+readOnly = true.- Уровень 3 split (DDD + Hexagonal): отдельный
<X>ViewRepositoryс read-DTO, write — через<X>Repositoryс агрегатом иFOR UPDATE.- Уровень 3 event-driven: read-model в отдельной таблице / Redis / ElasticSearch, sync через outbox + Kafka.
- Эволюция всегда снизу вверх: 1 → 2 → 3-split → 3-event-driven. Возврат назад — редкий и обычно говорит об ошибке планирования.
- Маркеры на Уровне 1 без enforcement (
readOnly = true, отдельный repository) — карго-культ.- Event-driven read-model с одним
<X>Repositoryдля R+W — несостыковка слоёв. Если read-инфра отдельная, интерфейс тоже отдельный.
CQRS — не «есть или нет», а шкала зрелости. На каждом уровне берётся ровно столько, сколько даёт пользу при текущем размере и нагрузке. Чрезмерная CQRS-инфраструктура на маленьком сервисе стоит сильно дороже, чем lightweight на старте и эволюция по мере роста. Раскрытие раздела 6 гайда.
Уровень 1 — CQRS не применяется
R-CQRS-TIER-1: на Уровне 1 (классический 3-tier layered Spring) CQRS не используется. UseCase + Handler без маркеров, общий repository, обычные @Transactional.
@Service
public class OrderService {
@Transactional
public OrderDto createOrder(CreateOrderRequest req) { ... }
@Transactional(readOnly = true)
public OrderDto getOrder(Long id) { ... }
}
Уровень 1 — внутренние утилиты, CRUD-микросервисы, тонкие proxy. Применять CQRS-маркеры здесь смысла нет: одна модель данных, простая структура, паттерны Spring Data покрывают всё.
Уровень 2 — lightweight CQRS обязателен
R-CQRS-TIER-2: на Уровне 2 (Use Case Pattern) маркеры UseCaseCommand / UseCaseQuery обязательны на всех use-case-классах. Read и write идут через один и тот же <X>Repository, но с разными режимами.
public record CreateOrderCommand(...) implements UseCaseCommand<OrderId> {}
public record GetOrderQuery(Long id) implements UseCaseQuery<OrderJson> {}
@Component
@RequiredArgsConstructor
class CreateOrderHandler implements UseCaseHandler<CreateOrderCommand, OrderId> {
private final OrderRepository orderRepository;
@Override
@Transactional
public OrderId handle(CreateOrderCommand cmd) {
Order order = new Order(cmd.customerId(), cmd.items());
orderRepository.save(order); // один и тот же OrderRepository
return order.id();
}
}
@Component
@RequiredArgsConstructor
class GetOrderHandler implements UseCaseHandler<GetOrderQuery, OrderJson> {
private final OrderRepository orderRepository;
private final OrderMapper orderMapper;
@Override
@Transactional(readOnly = true) // ← enforcement маркера Query
public OrderJson handle(GetOrderQuery query) {
Order order = orderRepository.findById(query.id(), SelectMode.NO_LOCK)
.orElseThrow(...);
return orderMapper.toJson(order);
}
}
Enforcement маркеров:
@Transactional(readOnly = true)на query-handler-ах. Это enforcement, без него маркерUseCaseQuery— лишь декорация.SelectMode.NO_LOCKна read-операциях,SelectMode.FOR_UPDATEна write. См. jOOQ → SelectMode.- Разная валидация. Command — Jakarta на DTO, query —
@Min/@Maxна page/size. - Метрики раздельные (
app_command_totalvsapp_query_total).
Read и write используют один OrderRepository — никакого отдельного OrderViewRepository. Это упрощение Уровня 2: разделять интерфейсы рано, выгоды мало.
Уровень 3 split — отдельный ViewRepository
R-CQRS-TIER-3: на Уровне 3 (DDD + Hexagonal) появляется явное разделение на интерфейсы. <X>Repository для записи (агрегат + FOR UPDATE), <X>ViewRepository для чтения (read-DTO).
// core/order/domain/port/out/OrderRepository.java
public interface OrderRepository {
Optional<Order> findById(OrderId id, SelectMode mode);
void save(Order order);
}
// core/order/domain/port/out/OrderViewRepository.java
public interface OrderViewRepository {
Optional<OrderSummary> findSummaryById(Long orderId);
Page<OrderSummary> search(Long customerId, OrderStatus status, Pageable p);
}
Что меняется:
- Два интерфейса в core/.
OrderRepositoryвозвращает агрегат,OrderViewRepositoryвозвращает read-DTO. Никакого пересечения. - Реализации обычно общие. Один Jooq-репозиторий может имплементировать оба или быть один на каждый — это деталь persistence/-модуля.
- Read-DTO как первоклассные record-ы. Лежат в
core/<bc>/dto/view/. Структура подчинена API/UI, не агрегату. - Запрос read-stuff через write-Repository — code smell. Если query-handler хочет получить
OrderSummary, он идёт вOrderViewRepository, не вOrderRepository.findById.
Read и write по-прежнему используют одно физическое хранилище — PostgreSQL. Разделение пока только на уровне типов и интерфейсов, не инфраструктуры.
Подробно — Query side, jOOQ → ViewRepository.
Уровень 3 event-driven — отдельное хранилище
R-CQRS-TIER-4: эволюция от split-варианта, когда нагрузки или паттерны чтения требуют отдельной инфраструктуры. Read-model переезжает в отдельную таблицу той же БД, в Redis, в ElasticSearch — то, что лучше подходит под паттерн.
write-side: read-side:
PostgreSQL order_summary (PG-таблица)
├── order (агрегат) ├── денормализованная схема
└── outbox └── индексы под query-сторону
↓
outbox-relay
↓
Kafka (order.events)
↓
read-side consumer
↓
UPSERT order_summary
Что добавляется:
- Outbox-таблица в write-БД (см. PG Runtime → outbox).
- Outbox-relay — scheduled bean с SKIP LOCKED loop.
- Kafka топик для событий.
- Read-side consumer в этом же сервисе или в другом.
- Idempotency-защита на consumer (
processed_eventили version-check). - Bootstrap-rebuilder для нового read-store и disaster recovery.
Стоимость:
- Eventual consistency (100ms–1s в норме, больше при деградации).
- Дополнительные runtime-компоненты на мониторинг.
- Новые failure modes: lag consumer, stuck outbox, рассинхронизация.
Окупается, когда write-сторона страдает от read-нагрузки или когда read-проекция фундаментально другая (full-text search, аналитические сводки). До этого порога — read-replica + кеш могут решить дешевле.
Эволюция всегда снизу вверх
R-CQRS-TIER-5: движение по уровням — строго 1 → 2 → 3-split → 3-event-driven. Возврат назад — редкое и обычно показывает, что начали слишком высоко.
Типичный путь жизни сервиса:
- Уровень 1 — стартовали как утилитный микросервис без явной бизнес-домены.
- Появился реальный бизнес-домен, перешли на Уровень 2 — ввели UseCase + Handler, маркеры
Command/Query. - Read-сторона стала отличаться от write (UI хочет проекций, аналитики) — перешли к Уровню 3 split с
OrderViewRepository. - p95 latency query пробил SLA или появился full-text search — перешли к Уровню 3 event-driven с отдельной read-таблицей или хранилищем.
Каждый переход — бизнес-обоснован: метриками, новыми фичами, фактом боли. Не «сходим в гости к Уровню 3 потому что это модно».
Возврат назад случается:
- Слили два сервиса обратно в один, и event-driven read-model в нём перестала иметь смысл — собрали обратно в один PG.
- Поменялся продукт, не нужны больше сложные read-проекции — упростили до 3-split.
Это редкость. Чаще возврат — это «переписать заново», что обычно решается рефакторингом без drop'а функциональности.
Что запрещено
Маркеры на Уровне 1 без enforcement
R-CQRS-TIER-X1: добавить implements UseCaseCommand к CreateOrderCommand на Уровне 1, при этом не используя UseCaseDispatcher, @Transactional(readOnly = true), отдельные read/write пути — это карго-культ.
// ПЛОХО — маркер ради маркера
public record CreateOrderCommand(...) implements UseCaseCommand<OrderJson> {}
@Service
public class OrderService {
@Transactional // ← без readOnly даже на чтении
public OrderJson createOrder(CreateOrderCommand cmd) { ... } // ← вызывается напрямую
@Transactional // ← read через тот же RW transactional
public OrderJson getOrder(GetOrderQuery query) { ... }
}
Что не так:
- Тип
UseCaseCommandничего не даёт — нет dispatcher-а, нет pipeline'а. @Transactional(readOnly = true)пропущен — performance hint потерян.- Метрики разделить нельзя — все calls идут через один
createOrder()-метод.
Если действительно нужен CQRS — переходи на Уровень 2 полноценно (UseCase + Handler + Dispatcher). Если не нужен — убери маркеры.
Event-driven read-model с одним Repository для R+W
R-CQRS-TIER-X2: на Уровне 3 event-driven с отдельной read-таблицей обязан быть отдельный <X>ViewRepository.
// ПЛОХО — отдельная read-таблица order_summary, но query идёт через OrderRepository
public interface OrderRepository {
Optional<Order> findById(OrderId id, SelectMode mode);
void save(Order order);
Optional<OrderSummary> findSummary(Long id); // ← read-метод в write-репо
Page<OrderSummary> search(Long customerId, Pageable p); // ← тоже
}
Что не так:
- Один интерфейс смешивает write-API (агрегат, FOR UPDATE) и read-API (read-DTO, пагинация).
- При добавлении нового read-метода придётся менять
OrderRepository— он растёт linearly с UI features. - В hexagonal-структуре это нарушает принцип: один интерфейс — одна ответственность.
Корректно: всегда отдельный <X>ViewRepository. Подробно — Query side, jOOQ → ViewRepository.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
UseCaseCommand / UseCaseQuery маркеры на Уровне 1 без enforcement | R-CQRS-TIER-X1 | Либо полный переход на Уровень 2, либо убрать маркеры |
| Event-driven read-model с одним Repository для R+W | R-CQRS-TIER-X2 | Отдельный <X>ViewRepository |
| Прыжок Уровень 1 → Уровень 3 event-driven без промежуточных шагов | R-CQRS-TIER-5 | Эволюция по метрикам, не по моде |
@Transactional(readOnly = true) отсутствует на query-handler-е | R-CQRS-TIER-2 | Обязательно для всех query-handler-ов |
Read-методы в основном <X>Repository на Уровне 3 | R-CQRS-QRY-X2 | Отдельный <X>ViewRepository |
Куда дальше
- CQRS → раздел 6. Уровень и эволюция — нормативные
R-CQRS-TIER-*. - Когда CQRS оправдан — пороги перехода между уровнями.
- Command side — write-handler на Уровне 2 / 3.
- Query side — read-handler с
<X>ViewRepository. - Read-model — где хранить отдельную проекцию на Уровне 3 event-driven.
- Sync via events — outbox + Kafka для event-driven.
- Use Case Pattern — основа Уровня 2.
- Hexagonal Style Guide — модульность Уровня 3.
- DDD Tactical Style Guide — агрегаты и события для write-side.