handbookНаписать

architecture/cqrs/README.md · тема · 4 языка · 32 теста

Разделение команд и запросов (CQRS) ​

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

Проблема ​

Одна модель заказа обслуживает и оформление, и экран "Мои заказы". Экрану нужны число позиций, сумма и статус по всем заказам клиента, и он грузит агрегаты целиком:

php
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, OrderPaid

клиент

PlaceOrder

OrderCommandHandler

PayOrder

OrderRepository

EventQueue

CustomerOrdersProjection

CustomerOrdersTable

CustomerOrdersQuery

Стрелок из правой части в левую нет: чтение зависит только от событий. Проекция идемпотентна - строка ищется по номеру заказа, и повторно доставленное 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.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS