Опирается на правила: 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 (маркеры Command / Query Protocol без разделения хранилищ) — бесплатное разделение, обязательно начиная с Уровня 2.
  • Маркеры реализуются через Protocol в Python — @dataclass(frozen=True) + Command[R] / Query[R] Protocol; enforcement — read-only AsyncSession на query-handler.
  • Денормализованная 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-ов с разными транзакционными настройками AsyncSession. В сложном — физически разные хранилища с синхронизацией через события. Между этими крайностями — спектр, и выбор точки на нём зависит от нагрузки, не от моды. Это раскрытие раздела 1 Python-биндинга.

Три уровня CQRS

R-CQRS-WHEN-1..3 описывают три точки спектра. Все три валидны, но в разных контекстах:

УровеньЧто разделеноКогда применять
Lightweight (маркеры)Command[R] / Query[R] Protocol; read-только через read-only AsyncSession без commitУровень 2+ — всегда
Read-projection в той же БДОтдельная таблица order_summary, отдельный OrderViewRepositoryjoin'ы 5+ таблиц, GROUP BY миллионов строк
Full splitРазные хранилища (PG + ElasticSearch / Redis / read-DB)read:write ≥ 10:1, поиск / аналитика

Эволюция всегда снизу вверх: сначала маркеры, потом отдельная таблица, потом отдельное хранилище.

Уровень 1: Lightweight на маркерах — обязательно с Уровня 2

R-CQRS-WHEN-1: на Уровне 2 (Use Case Pattern) маркерное разделение обязательно. В Python маркер — это Protocol с generic-параметром результата (Command[R], Query[R]). Реализация через @dataclass(frozen=True) — иммутабельность гарантируется компилятором типов.

from dataclasses import dataclass
from app.core.order.domain.model import OrderId

@dataclass(frozen=True)
class ConfirmOrder:           # Command[OrderId]
    order_id: OrderId
    idempotency_key: str

@dataclass(frozen=True)
class GetOrderSummary:        # Query[OrderSummaryView]
    order_id: OrderId

Enforcement читается через тип AsyncSession. Query-handler получает сессию, открытую в read-only режиме — commit на ней либо недоступен, либо запрещён явно на уровне фабрики:

class GetOrderSummaryHandler:
    def __init__(self, orders: OrderViewRepository) -> None:
        self._orders = orders

    async def handle(self, query: GetOrderSummary) -> OrderSummaryView:
        return await self._orders.by_id(query.order_id)

OrderViewRepository принимает AsyncSession без autocommit, сессия закрывается без commit() — случайный UPDATE в query-handler просто не сохранится в транзакции.

Что даёт маркер в Python-стеке:

  • Mypy / Pyright различают Command и Query на уровне типов: Handler[ConfirmOrder, OrderId]Handler[GetOrderSummary, OrderSummaryView].
  • Read-only сессия на query-handler — защита от случайного session.add() / session.execute(update(...)).
  • Валидация разная. Для command — Pydantic с model_config = ConfigDict(frozen=True) на входном DTO; для query — обычно только Annotated[int, Field(ge=1)] на page/size.
  • Метрики по семантике. app_command_total{name="ConfirmOrder"}app_query_total{name="GetOrderSummary"} — RED-метрики разделимы.

Read и write при этом ходят в один и тот же OrderRepository. Никакой дополнительной инфраструктуры не требуется.

Уровень 2: Денормализованная read-таблица в той же БД — middle-ground

R-CQRS-WHEN-3: когда query становится дорогим даже на read-replica, выделяем read-projection в отдельную таблицу той же БД. Не отдельное хранилище — это ещё рано.

Триггеры для перехода:

  • 5+ JOIN-ов в типичном query. Например, OrderSummaryView собирается из 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 внутри того же сервиса. OrderViewRepository в Python — Protocol:

from typing import Protocol
from app.core.order.port.view import OrderSummaryView
from app.core.order.domain.model import OrderId, CustomerId

class OrderViewRepository(Protocol):
    async def by_id(self, order_id: OrderId) -> OrderSummaryView: ...
    async def by_customer(
        self,
        customer_id: CustomerId,
        limit: int,
        offset: int,
    ) -> list[OrderSummaryView]: ...

Read-DTO — frozen dataclass или Pydantic-модель под нужды API, не агрегат:

from dataclasses import dataclass
from decimal import Decimal
from datetime import datetime

@dataclass(frozen=True)
class OrderSummaryView:
    order_id: int
    customer_name: str          # денормализовано
    status: str
    item_count: int             # pre-computed
    total_amount: Decimal
    created_at: datetime

Запрос GET /customers/{id}/orders теперь — один SELECT по индексу, без join-ов.

Уровень 3: Full CQRS с разделением хранилищ — только при метриках

R-CQRS-WHEN-2: full CQRS оправдан только при измеренной проблеме одного из четырёх типов:

  1. Read:write ratio ≥ 10:1. Типичный e-commerce: 1 заказ → 10 показов карточки в разных сценариях (история, поиск, аналитика, нотификации). При таком соотношении read-нагрузка диктует архитектуру.
  2. Принципиально разная структура. Full-text search по описаниям Product, фильтры по 20 фасетам, ранжирование по relevance — это не реляционная задача. ElasticSearch / OpenSearch с inverted index решает её на порядок эффективнее, чем PostgreSQL с pg_trgm.
  3. Read-нагрузка превышает throughput write-DB. PostgreSQL выдерживает 5k write/s, но 50k read/s того же объёма уже требует кеширования или отдельной реплики.
  4. Независимое масштабирование. Write-DB не должна страдать от read-нагрузки. Redis Cluster или ElasticSearch с sharding горизонтально масштабируются; PostgreSQL — нет.

Пример инфраструктуры full CQRS для сервиса каталога Product:

  • Write: PostgreSQL с агрегатом Product, FOR UPDATE на load, outbox-таблица product_outbox.
  • Outbox-relay: Kafka producer публикует ProductPublished, ProductPriceChanged, ProductDeactivated.
  • Read: ElasticSearch index products с денормализованной структурой; consumer обновляет документ при каждом событии.
  • FastAPI роутер: POST /products → command-handler в PG; GET /products?q=... → query-handler в ElasticSearch.
class SearchProductsHandler:
    def __init__(self, products: ProductSearchRepository) -> None:
        self._products = products

    async def handle(self, query: SearchProducts) -> ProductSearchPage:
        return await self._products.search(
            text=query.text,
            filters=query.filters,
            page=query.page,
            size=query.size,
        )

ProductSearchRepository — Protocol, за которым стоит ElasticSearch-адаптер. FastAPI-контроллер не знает о хранилище — только о Protocol.

Это дорогая инфраструктура: два хранилища, мониторинг обоих, eventual consistency, rebuilder-скрипты, recovery-сценарии. Окупается только когда read-replica + кеш уже не справляются с измеренной нагрузкой.

Что запрещено

АнтипаттернПравилоЧто взамен
Full CQRS «just in case» для нового сервисаR-CQRS-WHEN-X1lightweight маркеры Command/Query Protocol на старте, эволюция по метрикам
Разделение хранилищ без явной измеренной причиныR-CQRS-WHEN-X2read-replica + кеш до явной боли; CQRS-сплит — последний шаг
Lightweight маркеры пропущены на Уровне 2R-CQRS-WHEN-1Command[R] / Query[R] Protocol обязательны с Уровня 2
Маркеры без enforcement (read-only сессия)R-CQRS-TIER-X1query-handler получает только read-only AsyncSession; commit не вызывается
Уровень 3 без <X>ViewRepository ProtocolR-CQRS-TIER-X2при наличии отдельной read-инфраструктуры — отдельный Protocol-интерфейс

Куда дальше

  • Command side — как устроен write-handler с Command[R] Protocol и UoW.
  • Query side — read-handler с Query[R] и <X>ViewRepository Protocol.
  • Read-model — где хранить и как обновлять денормализованную проекцию.
  • Sync через события — outbox + Kafka: синхронизация write → read.
  • Уровень и эволюция — переход Уровень 1 → 2 → 3 по метрикам.