Опирается на правила:
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 (маркеры
Command/QueryProtocol без разделения хранилищ) — бесплатное разделение, обязательно начиная с Уровня 2.- Маркеры реализуются через
Protocolв Python —@dataclass(frozen=True)+Command[R]/Query[R]Protocol; enforcement — read-onlyAsyncSessionна 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, отдельный OrderViewRepository | join'ы 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 оправдан только при измеренной проблеме одного из четырёх типов:
- Read:write ratio ≥ 10:1. Типичный e-commerce: 1 заказ → 10 показов карточки в разных сценариях (история, поиск, аналитика, нотификации). При таком соотношении read-нагрузка диктует архитектуру.
- Принципиально разная структура. Full-text search по описаниям
Product, фильтры по 20 фасетам, ранжирование по relevance — это не реляционная задача. ElasticSearch / OpenSearch с inverted index решает её на порядок эффективнее, чем PostgreSQL сpg_trgm. - Read-нагрузка превышает throughput write-DB. PostgreSQL выдерживает 5k write/s, но 50k read/s того же объёма уже требует кеширования или отдельной реплики.
- Независимое масштабирование. 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-X1 | lightweight маркеры Command/Query Protocol на старте, эволюция по метрикам |
| Разделение хранилищ без явной измеренной причины | R-CQRS-WHEN-X2 | read-replica + кеш до явной боли; CQRS-сплит — последний шаг |
| Lightweight маркеры пропущены на Уровне 2 | R-CQRS-WHEN-1 | Command[R] / Query[R] Protocol обязательны с Уровня 2 |
| Маркеры без enforcement (read-only сессия) | R-CQRS-TIER-X1 | query-handler получает только read-only AsyncSession; commit не вызывается |
Уровень 3 без <X>ViewRepository Protocol | R-CQRS-TIER-X2 | при наличии отдельной read-инфраструктуры — отдельный Protocol-интерфейс |
Куда дальше
- Command side — как устроен write-handler с
Command[R]Protocol и UoW. - Query side — read-handler с
Query[R]и<X>ViewRepositoryProtocol. - Read-model — где хранить и как обновлять денормализованную проекцию.
- Sync через события — outbox + Kafka: синхронизация write → read.
- Уровень и эволюция — переход Уровень 1 → 2 → 3 по метрикам.