Опирается на правила: R-CQRS-WHEN-1R-CQRS-WHEN-3 и R-CQRS-WHEN-X1R-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, отдельный OrderViewRepositoryjoin'ы 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 оправдан только при измеренной проблеме одного из четырёх типов:

  1. Read:write ratio ≥ 10:1. Типичный e-commerce frontend: 1 заказ → 10 показов карточки в разных сценариях (история, поиск, аналитика, чек, нотификации). При таком соотношении read-нагрузка диктует архитектуру.
  2. Принципиально разная структура. Full-text search по описаниям Product, фильтры по 20 фасетам, ранжирование по relevance — это не реляционная задача. ElasticSearch / OpenSearch с inverted index решает её на порядок эффективнее, чем PostgreSQL с pg_trgm.
  3. Read-нагрузка превышает write-throughput write-DB. PostgreSQL может выдержать 5k write/s, но 50k read/s того же объёма данных уже требует кеширования или отдельной реплики, которая под нагрузкой read-only выдаст 100k+.
  4. 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-X1lightweight маркеры на старте, эволюция по метрикам
Разделение баз без явной измеренной причиныR-CQRS-WHEN-X2read-replica + кеш до явной боли
Lightweight маркеры пропущены на Уровне 2R-CQRS-WHEN-1UseCaseCommand / UseCaseQuery обязательны
Уровень 3 без <X>ViewRepositoryR-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.