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. Каждый переход должен быть обоснован метриками или новыми требованиями, а не желанием использовать «правильную архитектуру».
Типичный жизненный путь сервиса:
- Стартовал как CRUD-прокси — Уровень 1.
- Появился реальный домен, ввели Use Case Pattern — Уровень 2 с маркерами.
- UI начал просить проекции, не совпадающие с агрегатом — Уровень 3 split с
OrderViewRepository. - 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 оправдан — пороги перехода между уровнями.