8 мин чтенияBackend

CQRS на практике: разделение команд и запросов без лишней сложности

CQRS на практике: разделение команд и запросов без лишней сложности

Что такое CQRS

CQRS (Command Query Responsibility Segregation) — архитектурный паттерн, который разделяет операции над системой на два принципиально разных типа:

  • Command (команда) — действие, которое изменяет состояние системы.
  • Query (запрос) — операция, которая читает данные, не производя изменений.

Идея не нова: ещё в 1988 году Бертран Мейер в книге Object-Oriented Software Construction сформулировал принцип CQS (Command Query Separation) на уровне отдельных методов. CQRS поднимает ту же идею на уровень архитектуры: команды и запросы разделяются не просто по методам, а по классам, модулям, а иногда и по целым сервисам.

Термин популяризировали Грег Янг (Greg Young) и Уди Дахан (Udi Dahan) в конце 2000-х, когда стало ясно, что в сложных доменах модели чтения и записи живут по разным правилам и пытаются ужиться в одной сущности себе в убыток.

Главный вопрос: это команда или запрос?

Вопрос об операцииТипПример
Меняет ли она данные в системе?CommandСоздание заказа, списание средств
Только возвращает данные?QueryПолучение списка заказов, расчёт итога корзины

Критерий простой: если после операции данные в хранилище (БД, кэш, очередь) изменились — это Command. Если данные остались прежними — Query.

Два уровня CQRS

Лёгкий CQRS (в рамках одного сервиса)

Команды и запросы живут в одном приложении, работают с одной базой данных, но разделены по папкам, классам и семантике. Это самый распространённый вариант: он даёт 80 % пользы при 20 % усилий.

Один сервис
├── Commands → пишут в БД
└── Queries → читают из БД

Именно о нём пойдёт речь ниже.

Полный CQRS (раздельные модели)

Модель записи и модель чтения физически разделены: разные базы, разные схемы, синхронизация через события. Применяется в высоконагруженных системах, где нагрузка на чтение и запись отличается на порядки.

Command Side (PostgreSQL) ──события──▶ Query Side (Elasticsearch / Redis)

Это мощный, но дорогой инструмент. Если у вас нет миллионов запросов в секунду, скорее всего, он избыточен.

Примеры: команды и запросы

Чтобы не привязываться к одному домену, возьмём несколько типичных.

Команды (изменяют состояние)

КомандаЧто делает
PlaceOrderCommandСоздаёт заказ и списывает товары со склада
CancelSubscriptionCommandМеняет статус подписки на «отменена»
ResetUserPreferencesCommandУдаляет пользовательские настройки из кэша и БД
PublishArticleCommandПереводит статью из черновика в опубликованную

Запросы (читают данные)

ЗапросЧто возвращает
GetOrderHistoryQueryСписок заказов пользователя с пагинацией
GetDashboardStatsQueryАгрегированные метрики для дашборда
GetArticleByIdQueryТекст и метаданные статьи

Обратите внимание на именование:

  • Команды отражают действие: Place, Cancel, Reset, Publish.
  • Запросы начинаются с Get: GetOrderHistory, GetDashboardStats.

Структура кода

При лёгком CQRS логично организовать папки по типу операции и по доменной области:

Application/
├── Order/
│ ├── Command/
│ │ └── PlaceOrderCommand.php
│ ├── CommandHandler/
│ │ └── PlaceOrderHandler.php
│ ├── Query/
│ │ └── GetOrderHistoryQuery.php
│ ├── QueryHandler/
│ │ └── GetOrderHistoryHandler.php
│ └── DTO/
│   └── OrderSummary.php
│
├── Analytics/
│ ├── Query/
│ │ └── GetDashboardStatsQuery.php
│ ├── QueryHandler/
│ │ ├── GetDashboardStatsHandler.php
│ │ └── CachedGetDashboardStatsHandler.php
│ └── DTO/
│   └── DashboardStats.php

Соглашения:

  • Команды лежат в Command/, их обработчики — в CommandHandler/.
  • Запросы лежат в Query/, их обработчики — в QueryHandler/.
  • Каждый класс — одна операция. Никаких «универсальных» обработчиков.

Почему это важно

1. Явная семантика

По имени класса мгновенно понятно, что происходит:

// Очевидно: создаёт заказ, меняет состояние
$this->handler->handle(new PlaceOrderCommand($cartId, $userId));

// Очевидно: только читает историю
$this->handler->handle(new GetOrderHistoryQuery($userId, $page));

Читая код ревью, вы сразу видите, где система мутирует данные, а где нет. Это снижает когнитивную нагрузку на порядок.

2. Разные стратегии обработки

АспектCommandQuery
Транзакции БДОбязательныНе нужны
КэшированиеНельзя кэшироватьМожно и нужно
Валидация бизнес-правилПолнаяМинимальная
МасштабированиеMaster-нода БДRead-replicas
ИдемпотентностьНужно проектироватьЕстественна
Логирование / аудитКритичноОпционально

Кэширование команды — ошибка, которая приводит к потере данных. Кэширование запроса — оптимизация. Разделение исключает путаницу.

3. Разные модели данных

  • Команды работают с доменными агрегатами (Order, Subscription) и соблюдают бизнес-инварианты.
  • Запросы возвращают специализированные DTO (OrderSummary, DashboardStats), оптимизированные под конкретный экран или API-эндпоинт.
  • Не нужно тянуть весь граф объектов заказа, чтобы показать пользователю список из десяти строк.
  • Query-модель плоская и лёгкая.

4. Тестируемость

Команды тестируются через побочный эффект: «после выполнения в БД появилась запись X». Запросы тестируются через возвращаемое значение: «для входных данных Y возвращается DTO Z». Это разные типы тестов, и разделение позволяет не смешивать их.

Когда граница размывается

Операция может и читать, и писать. Например, обработчик размещения заказа:

  1. Читает состав корзины из Redis.
  2. Проверяет наличие товаров на складе.
  3. Создаёт запись Order в БД.
  4. Уменьшает остатки на складе.

Это Command, потому что главное — изменение состояния. Чтение здесь вспомогательное.

Правило: определяйте тип по конечному эффекту. Если данные изменились — Command. Чтение внутри команды не делает её запросом.

Связь с другими паттернами

Mediator / Message Bus

Команды и запросы часто передаются через шину сообщений (Mediator). Вызов выглядит единообразно:

$this->messageBus->dispatch(new PlaceOrderCommand(...));
$this->messageBus->dispatch(new GetOrderHistoryQuery(...));

Шина не знает, что внутри: она просто маршрутизирует объект в нужный обработчик. Это убирает жёсткую связность между контроллером и бизнес-логикой.

Паттерн __invoke

Обработчики удобно делать «вызываемыми» объектами:

final class PlaceOrderHandler
{
    public function __invoke(PlaceOrderCommand $command): OrderId
    {
        // логика создания заказа
    }
}

Это упрощает DI-контейнеру регистрацию и делает вызов лаконичным.

Repository и Read-модель

В лёгком CQRS команды обычно идут через Repository (паттерн для работы с агрегатами), а запросы — через Read-модель (прямые SQL-запросы, Query Builder, Elasticsearch). Не нужно прогонять аналитический запрос через агрегат и коллекцию объектов.

Частые заблуждения

«CQRS требует две разные базы данных»

Нет. Полный CQRS с раздельными хранилищами — продвинутый вариант для высоконагруженных систем. Лёгкий CQRS на уровне кода даёт главные преимущества (семантика, кэширование, тестируемость) без усложнения инфраструктуры.

«Каждый SELECT к БД — это Query»

Нет. Если метод читает данные как часть команды (например, проверка «есть ли товар на складе?» внутри PlaceOrderHandler) — это часть Command, а не отдельный Query. Query — это самостоятельная операция чтения, инициированная извне.

«Command не может возвращать данные»

Может, но минимум. PlaceOrderHandler возвращает OrderId — этого достаточно для HTTP-ответа 201 Created. Но команда не должна возвращать большие структуры данных: для этого есть Query.

«CQRS — это про микросервисы»

Нет. CQRS отлично работает в монолите. Более того, в монолите он часто приносит больше пользы, потому что без него границы между чтением и записью размываются быстрее.

«Нужен Event Sourcing»

CQRS и Event Sourcing часто упоминаются вместе, но это независимые паттерны. Можно использовать CQRS без Event Sourcing и наоборот. Связка имеет смысл в сложных доменах с аудитом, но не является обязательной.

Когда CQRS не нужен

  • CRUD-приложение без бизнес-логики. Если у вас пять таблиц и стандартный админ-панель, разделение на команды и запросы добавит boilerplate без реальной пользы.
  • Прототип / MVP. На этапе проверки гипотезы скорость важнее архитектуры.
  • Команда не знакома с паттерном. CQRS, внедрённый формально, без понимания, превращается в ритуал и усложняет онбординг.

CQRS оправдан, когда:

  • Операции чтения и записи имеют разную нагрузку.
  • Бизнес-правила при записи сложны и требуют явного моделирования.
  • Разные клиенты (веб, мобильное приложение, API) нуждаются в разных проекциях данных.
  • Команда растёт и нужна чёткая конвенция, чтобы не скатиться в «богатые сервисы».

Антипаттерны

АнтипаттернПочему плохо
SaveOrderCommand, которая и создаёт, и обновляет, и удаляетНарушает единственную ответственность; разбейте на Place, Update, Cancel
Query с побочным эффектом (логирует, шлёт уведомления)Скрытая мутация; кэширование такого запроса сломает логику
Один «God Handler» на все командыУбивает всю пользу от разделения
Возврат полного агрегата из QueryТянет лишние данные, связывает read-модель с доменной
Команда без валидации инвариантовCQRS не отменяет проверку бизнес-правил

Чек-лист для внедрения

  1. Определите доменные области (Order, Analytics, Identity…).
  2. Разнесите папки: Command/, CommandHandler/, Query/, QueryHandler/, DTO/.
  3. Договоритесь об именовании: глагол действия для команд, Get для запросов.
  4. Настройте шину / Mediator, чтобы контроллеры не знали о конкретных обработчиках.
  5. Запретите кэширование на уровне команд, разрешите на уровне запросов.
  6. Пишите тесты по типу: для команд — проверка побочного эффекта, для запросов — проверка возвращаемых данных.
  7. Проведите ревью: убедитесь, что ни один Query не мутирует состояние.

Итоги

CQRS — не обязательный атрибут «серьёзной архитектуры».

Это инструмент упорядочивания, который:

  • делает намерения кода явными,
  • развязывает руки для кэширования и масштабирования чтения,
  • упрощает тестирование и код-ревью,
  • задаёт понятную конвенцию для растущей команды.

Начните с лёгкого CQRS: разделите папки, дайте классам говорящие имена, уберите SELECT-запросы из команд. Это займёт несколько часов \ дней, а окупаться будет годами.