Опирается на правила: R-CQRS-TIER-1R-CQRS-TIER-5 и R-CQRS-TIER-X1R-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_total vs app_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. Уровень 1 — стартовали как утилитный микросервис без явной бизнес-домены.
  2. Появился реальный бизнес-домен, перешли на Уровень 2 — ввели UseCase + Handler, маркеры Command / Query.
  3. Read-сторона стала отличаться от write (UI хочет проекций, аналитики) — перешли к Уровню 3 split с OrderViewRepository.
  4. 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 без enforcementR-CQRS-TIER-X1Либо полный переход на Уровень 2, либо убрать маркеры
Event-driven read-model с одним Repository для R+WR-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 на Уровне 3R-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.