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. Разные стратегии обработки
| Аспект | Command | Query |
|---|---|---|
| Транзакции БД | Обязательны | Не нужны |
| Кэширование | Нельзя кэшировать | Можно и нужно |
| Валидация бизнес-правил | Полная | Минимальная |
| Масштабирование | Master-нода БД | Read-replicas |
| Идемпотентность | Нужно проектировать | Естественна |
| Логирование / аудит | Критично | Опционально |
Кэширование команды — ошибка, которая приводит к потере данных. Кэширование запроса — оптимизация. Разделение исключает путаницу.
3. Разные модели данных
- Команды работают с доменными агрегатами (
Order,Subscription) и соблюдают бизнес-инварианты. - Запросы возвращают специализированные DTO (
OrderSummary,DashboardStats), оптимизированные под конкретный экран или API-эндпоинт. - Не нужно тянуть весь граф объектов заказа, чтобы показать пользователю список из десяти строк.
- Query-модель плоская и лёгкая.
4. Тестируемость
Команды тестируются через побочный эффект: «после выполнения в БД появилась запись X». Запросы тестируются через возвращаемое значение: «для входных данных Y возвращается DTO Z». Это разные типы тестов, и разделение позволяет не смешивать их.
Когда граница размывается
Операция может и читать, и писать. Например, обработчик размещения заказа:
- Читает состав корзины из Redis.
- Проверяет наличие товаров на складе.
- Создаёт запись
Orderв БД. - Уменьшает остатки на складе.
Это 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 не отменяет проверку бизнес-правил |
Чек-лист для внедрения
- Определите доменные области (
Order,Analytics,Identity…). - Разнесите папки:
Command/,CommandHandler/,Query/,QueryHandler/,DTO/. - Договоритесь об именовании: глагол действия для команд, Get для запросов.
- Настройте шину / Mediator, чтобы контроллеры не знали о конкретных обработчиках.
- Запретите кэширование на уровне команд, разрешите на уровне запросов.
- Пишите тесты по типу: для команд — проверка побочного эффекта, для запросов — проверка возвращаемых данных.
- Проведите ревью: убедитесь, что ни один Query не мутирует состояние.
Итоги
CQRS — не обязательный атрибут «серьёзной архитектуры».
Это инструмент упорядочивания, который:
- делает намерения кода явными,
- развязывает руки для кэширования и масштабирования чтения,
- упрощает тестирование и код-ревью,
- задаёт понятную конвенцию для растущей команды.
Начните с лёгкого CQRS: разделите папки, дайте классам говорящие имена, уберите SELECT-запросы из команд. Это займёт несколько часов \ дней, а окупаться будет годами.