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

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

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

Уровень 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 — для запроса. Это не просто соглашение — маркеры дают enforcement:

  • @Transactional(readOnly = true) на query-handler-е — подсказка базе данных, что записи не будет. Без этого маркер UseCaseQuery ни на что не влияет.
  • SelectMode.NO_LOCK на операциях чтения — не блокируем строки, которые не изменяем.
  • Раздельные метрики для команд и запросов.
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);
        return order.id();
    }
}

@Component
@RequiredArgsConstructor
class GetOrderHandler implements UseCaseHandler<GetOrderQuery, OrderJson> {
    private final OrderRepository orderRepository;
    private final OrderMapper orderMapper;

    @Override
    @Transactional(readOnly = true)
    public OrderJson handle(GetOrderQuery query) {
        Order order = orderRepository.findById(query.id(), SelectMode.NO_LOCK)
            .orElseThrow(...);
        return orderMapper.toJson(order);
    }
}

Оба хендлера используют один и тот же OrderRepository. Разделять интерфейсы пока рано — выгоды от этого шага на этом уровне не будет.

Уровень 3 split — отдельный ViewRepository

Со временем read-сторона начинает отличаться от write. UI просит проекции с полями из нескольких таблиц, аналитики хотят сводки — а агрегат спроектирован под запись, не под отображение. Запрашивать OrderSummary через OrderRepository становится неудобно.

Решение — два отдельных интерфейса в домене:

// 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);
}

OrderRepository возвращает агрегат и используется в command-handler-ах. OrderViewRepository возвращает read-DTO и используется в query-handler-ах. Никакого пересечения.

Read-DTO (OrderSummary) — самостоятельные record-ы. Их структура подчинена тому, что нужно API и UI, а не внутреннему устройству агрегата.

Физически данные по-прежнему в одном PostgreSQL — разделение пока только на уровне типов и интерфейсов, не инфраструктуры.

Частая ошибка: добавить методы вроде findSummary() прямо в OrderRepository. Так OrderRepository будет расти вместе с каждой новой функциональностью UI, смешивая write-API с read-API в одном интерфейсе.

Уровень 3 event-driven — отдельное хранилище

Следующий шаг нужен, когда read-нагрузка начинает мешать write-стороне, или когда паттерн чтения фундаментально отличается — например, нужен полнотекстовый поиск или аналитические сводки по миллионам записей.

Read-model переезжает в отдельную таблицу, Redis или Elasticsearch. Синхронизация идёт через outbox и Kafka:

write-side:                       read-side:
  PostgreSQL                        order_summary (денормализованная таблица)
  ├── order (агрегат)               └── индексы под query-сторону
  └── outbox (атомарно с записью)
       ↓ outbox-relay
  Kafka (order.events)
       ↓ read-side consumer
  UPSERT order_summary

Что добавляется к предыдущему уровню:

  • 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.

Возврат назад случается редко и обычно означает, что начали слишком высоко. Если слили два сервиса в один и event-driven read-model потеряла смысл — упрощают до split или до Уровня 2.

Частые ошибки

Маркеры без enforcement. Добавить implements UseCaseCommand к команде на Уровне 1, при этом вызывать её напрямую через @Service, без readOnly = true на запросах — это пустое оформление. Маркер должен что-то менять в поведении, иначе он лишний.

Read-методы в write-репозитории на Уровне 3. Если выделен OrderViewRepository, но запросы вроде findSummary() остаются в OrderRepository — смысл разделения теряется. OrderRepository будет расти вместе с UI, смешивая ответственности.

Прыжок с Уровня 1 сразу на event-driven. Outbox, Kafka, отдельная read-таблица — это инфраструктура с нетривиальной ценой. Без реальной нагрузки она создаёт сложность, но не решает проблему.

Коротко

  • 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.
  • Каждый переход — по метрикам и реальной боли, не по моде.
  • Маркеры без enforcement — формализм. Не добавляйте их, если нечего обеспечивать.

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

  • Command side — write-handler на Уровне 2 и 3.
  • Query side — read-handler с ViewRepository.
  • Read-model — где хранить отдельную проекцию на Уровне 3 event-driven.
  • Sync via events — outbox и Kafka для синхронизации.
  • Когда CQRS оправдан — пороги перехода между уровнями.