Разделение команд и запросов (CQRS)
Команды меняют модель записи и ничего не возвращают, запросы читают отдельную модель, собранную под экран, и не трогают агрегат.
Проблема
Одна модель заказа обслуживает и оформление, и экран "Мои заказы". Экрану нужны число позиций, сумма и статус по всем заказам клиента, и он грузит агрегаты целиком:
final class MyOrdersController
{
public function index(string $customerId): array
{
$rows = [];
foreach ($this->orders->findByCustomer($customerId) as $order) { // full aggregates with lines
$rows[] = [
'id' => $order->id,
'items' => array_sum(array_map(fn($l) => $l->qty, $order->lines())),
'total' => $order->total(),
'status' => $order->status(),
];
}
return $rows; // N orders -> N joins with lines on every page view
}
}Чтение в десятки раз чаще записи, но платит за сложность модели записи: агрегату добавляют геттеры, ленивые связи и индексы ради экранов, а каждый новый отчёт тянет ещё одно поле в доменную модель. Оптимизировать одно, не мешая другому, нельзя.
Решение
Разделить путь записи и путь чтения:
- Команда (
PlaceOrder,PayOrder) - намерение изменить систему. Обработчик команд проверяет правила на модели записи (Order), сохраняет её и публикует событие (OrderPlaced,OrderPaid). Модель записи хранит только то, что нужно правилам: итог для проверки оплаты и признак оплаты. - Проекция (
CustomerOrdersProjection) подписана на события и обновляет модель чтения - таблицуCustomerOrderRow, уже в той форме, которую показывает экран: номер, число позиций, сумма, статус. - Запрос (
CustomerOrdersQuery) читает только эту таблицу. У него нет доступа к репозиторию заказов, поэтому он не может ни поменять агрегат, ни заставить его загрузиться.
Между командой и запросом стоит очередь событий. Пока проекция не обработала событие, модель чтения отстаёт: это не баг, а цена разделения (eventual consistency), и интерфейс должен её выдерживать.
Структура
Стрелок из правой части в левую нет: чтение зависит только от событий. Проекция идемпотентна - строка ищется по номеру заказа, и повторно доставленное OrderPlaced не создаёт вторую строку. Брокеры доставляют события "хотя бы один раз", так что без этого таблица разъедется после первого перезапуска.
Как это собирается
Composition root создаёт два хранилища (репозиторий заказов и таблицу чтения), очередь между ними и по обработчику на каждую сторону. Контроллер оформления принимает PlaceOrder и вызывает OrderCommandHandler: тот проверяет наличие, считает сумму в копейках, сохраняет Order и кладёт OrderPlaced в очередь. Сразу после команды запрос "Мои заказы" возвращает пусто - проекция ещё не отработала. В приложении она живёт отдельным потребителем очереди; в примере её вызывают явно, и после этого запрос видит строку placed. Оплата проходит тот же путь, и строка становится paid.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Всё в памяти: репозиторий - словарь, очередь - список, таблица чтения - словарь по клиенту и номеру заказа. В проекте это две разные базы или хотя бы две схемы, а очередь - брокер или outbox-таблица.
| Язык | Особенность |
|---|---|
| PHP | Команды, события и строка чтения - final readonly class; объединение типов из OrderPlaced и OrderPaid в сигнатурах вместо общего интерфейса событий; запрос - отдельный класс CustomerOrdersQuery с одной зависимостью |
| Go | Команды и строки - структуры; событие - закрытый интерфейс Event с методом isEvent, проекция разбирает его switch e.(type); запрос - функция MyOrders(table, id), которая физически не видит Commands |
| Python | События - frozen dataclass, их объединение - алиас type Event; проекция - функция apply с match по классам событий; таблица - вложенный dict, который хранит порядок вставки |
| TypeScript | События - размеченное объединение по полю type, switch сужает тип; таблица - Map в Map; queue.splice(0) забирает и очищает очередь одной операцией |
Запуск: make test T=architecture/cqrs
Плюсы и минусы
Тесты одинаковы во всех языках: команда меняет модель записи, а модель чтения пуста до проекции; проекция по трём событиям двух клиентов строит строку paid только для нужного клиента; повторное событие не дублирует строку; отклонённая команда (нет остатка, неизвестный sku, пустой заказ, неизвестный заказ) не публикует событий; правила оплаты проверяет модель записи.
- ✅ Модель записи держит только правила, модель чтения - только то, что показывает экран. Их меняют и масштабируют независимо.
- ✅ Чтение дешёвое: одна выборка готовых строк без джойнов и сборки агрегатов. Под новый экран заводят новую проекцию, не трогая домен.
- ✅ Модель чтения можно перестроить с нуля, прогнав события заново, например после ошибки в проекции.
- ❌ Модель чтения отстаёт от записи. Пользователь оформил заказ и не видит его в списке - это надо решать в интерфейсе (показать результат команды, подождать, опросить).
- ❌ Больше движущихся частей: очередь, потребитель, повторная доставка, мониторинг отставания, идемпотентность проекций.
- ❌ Данные дублируются: сумма заказа лежит и в модели записи, и в таблице чтения, и ошибка в проекции даёт расхождение, которое видно только пользователю.
Когда не применять
- CRUD и формы "отредактировал - сразу увидел". Если экран показывает ровно то, что записали, две модели - это две копии одного и того же плюс задержка.
- Нагрузка на чтение небольшая, а отчёты укладываются в SQL-запрос или представление поверх тех же таблиц. Отдельная read-модель в той же базе (view, материализованное представление) часто даёт 80% пользы без очереди.
- Бизнес не готов к отставанию: остатки на складе, баланс счёта перед списанием. Решения, которые принимают по прочитанным данным, должны идти через модель записи, а не через проекцию.
- Команда маленькая и не готова сопровождать очередь, повторную доставку и перестройку проекций. CQRS - это не "два класса вместо одного", это эксплуатационная нагрузка.
- CQRS не требует Event Sourcing и отдельной базы. Начинать стоит с разделения кода команд и запросов в одном приложении над одной базой, а очередь добавлять, когда чтение действительно упёрлось в модель записи.
Отличия между языками
- PHP - объединение типов в сигнатуре (
OrderPlaced|OrderPaid) заменяет маркерный интерфейс событий и проверяется PHPStan. Разделение сторон видно по конструкторам:CustomerOrdersQueryпринимает толькоCustomerOrdersTable. - Go - закрытая сумма типов делается интерфейсом с неэкспортируемым методом
isEvent: снаружи пакета новое событие не добавить. Запрос - обычная функция, а не объект: зависимость от таблицы видна в параметрах. Очередь передаётся указателем на срез, потому чтоappendвозвращает новый срез. - Python -
matchс позиционными шаблонами (case OrderPlaced(order_id, _, count, total)) разбирает событие по полям dataclass, аreplaceизdataclassesдаёт новую строку вместо мутации. Обработчик команд остаётся классом, потому что держит три зависимости, а проекция и запрос - функции. - TypeScript - размеченное объединение
OrderEventиswitch (event.type)дают компилятору проверить, что каждое событие обработано. Строка чтения собирается явно из полей события: spread{ ...event }протащил бы в таблицу лишниеtypeиcustomerId.
Связанные темы
- Тактический DDD - агрегат, который в CQRS становится моделью записи
- Event Sourcing - события не только кормят проекции, но и сами становятся моделью записи
- Доменное событие (Domain Event) - откуда берутся
OrderPlacedиOrderPaid - Outbox - как не потерять событие между сохранением заказа и очередью
- Репозиторий (Repository) - хранилище модели записи