Опирается на правила:
R-CQRS-WHEN-1…R-CQRS-WHEN-3иR-CQRS-WHEN-X1…R-CQRS-WHEN-X2из CQRS Style Guide → раздел 1. Когда CQRS оправдан.
Важно знать
- CQRS — паттерн с ценой: отдельные read-классы, отдельная синхронизация, eventual consistency. Применяем когда выгода покрывает цену, не «потому что красиво».
- Lightweight CQRS (маркеры
UseCaseCommand/UseCaseQueryбез разделения хранилищ) — бесплатное разделение, обязательно начиная с Уровня 2.- Денормализованная read-таблица в той же БД — middle-ground: один PostgreSQL, отдельная схема для чтения, синхронизация через outbox + Kafka внутри сервиса.
- Full CQRS с разделением хранилищ (write-DB + Redis/ElasticSearch/read-DB) — только при read:write ratio ≥ 10:1, фундаментально разной структуре read-проекции или необходимости read-scaling без vertical scaling write-DB.
- Full CQRS «just in case» для нового сервиса без явной проблемы — карго-культ. Стартуем с lightweight, эволюционируем по метрикам.
- Разделение баз без явной причины добавляет sync complexity, eventual consistency и инфра-стоимость. Должна быть конкретная боль, которую измерили.
CQRS (Command Query Responsibility Segregation) — это разделение модели записи и модели чтения. В простом виде — две группы handler-ов с разными @Transactional-настройками. В сложном — две физически разных СУБД с синхронизацией через события. Между этими крайностями — спектр, и выбор точки на нём зависит от нагрузки, не от моды. Раскрытие раздела 1 гайда.
Три уровня CQRS
R-CQRS-WHEN-1..3 описывают три точки спектра. Все три валидны, но в разных контекстах:
| Уровень | Что разделено | Когда применять |
|---|---|---|
| Lightweight (маркеры) | UseCaseCommand / UseCaseQuery интерфейсы; read-методы с readOnly = true | Уровень 2+ — всегда |
| Read-projection в той же БД | Отдельная таблица order_summary, отдельный OrderViewRepository | join'ы 5+ таблиц, GROUP BY миллионов строк |
| Full split | Разные хранилища (PG + ElasticSearch / Redis / read-DB) | read:write ≥ 10:1, поиск / аналитика |
Эволюция всегда снизу вверх: сначала маркеры, потом отдельная таблица, потом отдельное хранилище. Назад движение возможно, но обычно говорит о неверном first-step решении.
Уровень 1: Lightweight на маркерах — обязательно с Уровня 2
R-CQRS-WHEN-1: на Уровне 2 (Use Case Pattern) маркерное разделение обязательно. Это не стоит ничего и даёт enforcement через типы.
public record ConfirmOrderCommand(Long orderId, String idempotencyKey)
implements UseCaseCommand<Order> {}
public record GetOrderSummaryQuery(Long orderId)
implements UseCaseQuery<OrderSummary> {}
Что даёт маркер:
- Компилятор различает read и write. Handler типизирован:
UseCaseHandler<ConfirmOrderCommand, Order>≠UseCaseHandler<GetOrderSummaryQuery, OrderSummary>. @Transactional(readOnly = true)на query-handler-ах — отдельная настройка пула, hints в PostgreSQL, защита от случайного UPDATE.- Валидация разная. Command — Jakarta
@NotNull/@Patternна DTO; query — обычно только@Min/@Maxна page/size. - Аудит и метрики легче.
app_command_total{name=ConfirmOrderCommand}≠app_query_total{name=GetOrderSummaryQuery}— RED-метрики разделимы по семантике.
Read и write при этом ходят в один и тот же OrderRepository. Никакой дополнительной инфраструктуры не требуется — только дисциплина в типах.
Уровень 2: Денормализованная read-таблица в той же БД — middle-ground
R-CQRS-WHEN-3: когда query становится дорогим (даже на read-replica), выделяем read-projection в отдельную таблицу той же БД. Не отдельное хранилище — этого ещё рано.
Триггеры для перехода:
- 5+ JOIN-ов в типичном query. Например,
OrderSummaryсобирается изorder,order_item,product,customer,payment,shipment— каждый запрос грузит CPU и память. - Тяжёлые aggregations.
GROUP BY customer_id, DATE(created_at)по миллионам строк для дашборда → каждая загрузка дашборда стоит секунду CPU. - Несовпадение структуры. UI хочет «у заказа есть
customer_name,total_items,last_status_change», и собирать это каждый раз — лишняя работа. Денормализуем.
Реализация: отдельная таблица order_summary с денормализованными полями, отдельный OrderSummaryViewRepository, синхронизация через outbox + Kafka в том же сервисе (event publisher → in-process consumer → UPDATE order_summary).
CREATE TABLE order_summary (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
customer_name TEXT NOT NULL, -- денормализовано
status TEXT NOT NULL,
item_count INTEGER NOT NULL, -- pre-computed
total_amount NUMERIC(19,4) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX ix_order_summary_customer ON order_summary(customer_id, created_at DESC);
Запрос GET /customers/{id}/orders теперь — один SELECT по индексу, без join-ов.
Уровень 3: Full CQRS с разделением хранилищ — только при метриках
R-CQRS-WHEN-2: full CQRS оправдан только при измеренной проблеме одного из четырёх типов:
- Read:write ratio ≥ 10:1. Типичный e-commerce frontend: 1 заказ → 10 показов карточки в разных сценариях (история, поиск, аналитика, чек, нотификации). При таком соотношении read-нагрузка диктует архитектуру.
- Принципиально разная структура. Full-text search по описаниям Product, фильтры по 20 фасетам, ранжирование по relevance — это не реляционная задача. ElasticSearch / OpenSearch с inverted index решает её на порядок эффективнее, чем PostgreSQL с
pg_trgm. - Read-нагрузка превышает write-throughput write-DB. PostgreSQL может выдержать 5k write/s, но 50k read/s того же объёма данных уже требует кеширования или отдельной реплики, которая под нагрузкой read-only выдаст 100k+.
- Need for independent scaling. Write-DB не должна страдать от read-нагрузки. Read-store горизонтально масштабируется (Redis cluster, ElasticSearch sharded), write — нет.
Пример инфраструктуры full CQRS:
- Write: PostgreSQL с агрегатом
Order, FOR UPDATE на load, outbox-таблица. - Outbox-relay: kafka producer публикует
OrderConfirmed,OrderShipped, … - Read: ElasticSearch index
ordersс денормализованной структурой; consumer обновляет документ при каждом событии. - API:
POST /orders→ write-handler в PG;GET /orders?q=...→ query-handler в ElasticSearch.
Это уже дорогая инфраструктура: два хранилища, мониторинг и того и другого, eventual consistency, rebuilders, recovery-сценарии. Окупается только когда альтернативой стоит read-replica + кеш не справляются.
Что запрещено
Full CQRS «just in case» для нового сервиса
R-CQRS-WHEN-X1: новый сервис стартует с lightweight маркеров, не с разделения хранилищ. Причина — ты не знаешь, какие будут реальные паттерны нагрузки.
// ПЛОХО — новый сервис с full CQRS «потому что красиво»
// Архитектура:
// - PostgreSQL для Order (write)
// - ElasticSearch для OrderProjection (read)
// - Kafka для синхронизации
// - Спустя 6 месяцев: 500 заказов в день, read-нагрузка 5 req/s
//
// Реальный размер задачи покрывался бы одним PG + lightweight маркерами.
// Вместо этого команда полгода поддерживает ElasticSearch, расследует
// рассинхронизации, пишет реконструкторы — без бизнес-выгоды.
Делай наоборот: lightweight на старте, измеряй реальную нагрузку, эволюционируй когда метрики (p95 latency, query CPU, read:write ratio) покажут конкретный порог.
Разделение баз без явной причины
R-CQRS-WHEN-X2: «у нас будет много чтения» — не причина. Должна быть измеренная проблема:
- p95 query latency растёт линейно с числом записей и пробил SLA.
- CPU PostgreSQL на 80%+ от read-нагрузки, write деградирует.
- Бизнес добавил функционал (поиск по тексту, аналитика), который реляционка не тянет.
До этого порога — read-replica PostgreSQL и кеш покрывают 90% случаев. CQRS-сплит — последний шаг, не первый.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Full CQRS «just in case» для нового сервиса | R-CQRS-WHEN-X1 | lightweight маркеры на старте, эволюция по метрикам |
| Разделение баз без явной измеренной причины | R-CQRS-WHEN-X2 | read-replica + кеш до явной боли |
| Lightweight маркеры пропущены на Уровне 2 | R-CQRS-WHEN-1 | UseCaseCommand / UseCaseQuery обязательны |
Уровень 3 без <X>ViewRepository | R-CQRS-TIER-X2 | отдельный интерфейс для read-проекции |
Куда дальше
- CQRS → раздел 1. Когда CQRS оправдан — нормативные формулировки
R-CQRS-WHEN-*. - Command side — как устроен write-handler с маркером
UseCaseCommand. - Query side — read-handler с
UseCaseQueryи<X>ViewRepository. - Read-model — где хранить и как обновлять денормализованную проекцию.
- Уровень и эволюция — переход 1 → 2 → 3 по метрикам.
- Use Case Pattern — откуда взяты маркеры
UseCaseCommand/UseCaseQuery. - Kafka → outbox — механизм синхронизации write → read.