CQRS Application Layer поверх API Platform
В Symfony HTTP-обработчик часто становится use-case-ом по умолчанию. Пока операция живёт в одном endpoint’е, это выглядит экономно. Когда тот же сценарий нужно вызвать через CLI, Messenger worker или webhook, транспорт начинает диктовать структуру приложения, а одна и та же бизнес-операция расходится по нескольким входам.
Здесь нужен не ещё один контроллер и не «магия» вокруг API Platform. Нужна отдельная граница приложения: транспорт принимает внешний запрос, Application Layer представляет use-case, домен отвечает за правила и инварианты. elriseio/application-layer-bundle — реализация этой границы, а не предмет статьи сам по себе.
Откуда это
В моей практике один и тот же use-case регулярно приходилось выставлять через HTTP, Messenger worker, CLI и webhook. Проблема была не в количестве транспортов, а в том, что каждый из них начинал по-своему собирать вход, вызывать доменную логику и оформлять результат. Эта статья фиксирует архитектурный принцип, который удерживает такие пути одинаковыми.
Я не сравниваю разные способы построения Application Layer. Я предлагаю один из вариантов, который помогает собрать более чистую архитектуру: отделить транспорт от use-case и домена, а затем закрепить границу явными command/query-контрактами. Бандл — конкретная реализация этого подхода. Код, wiring, API и тесты находятся на странице проекта бандла и в репозитории.
Проблема: транспорт становится use-case-ом
У входящего запроса есть три разных аспекта:
| Аспект | Ответственность |
|---|---|
| Транспорт | HTTP, OpenAPI, content negotiation, rate limits, authentication, формат ответа |
| Application Layer | вход use-case-а, выбор command или query, вызов handler-а, application-level orchestration |
| Домен | агрегаты, инварианты, доменные сервисы и правила изменения состояния |
В неудачной структуре эти аспекты сворачиваются в контроллер. Он читает payload, решает, что делать, вызывает репозиторий, знает формат ответа и иногда сам управляет очередью. Такой код кажется прямым, потому что весь путь виден в одном файле. Но это ложная простота: контроллер становится единственным местом, где существует use-case.
Следующий транспорт уже не может вызвать этот use-case напрямую. Его приходится либо повторять, либо вытаскивать куски из контроллера в сервисы без ясного контракта. Через некоторое время одинаковые операции имеют разные правила валидации, авторизации, транзакций и обработки ошибок. Грабли были одни и те же: проблема появляется не в домене, а на границе между входом и применением бизнес-правил.
DDD предлагает ввести Application Layer, который владеет границей между транспортом и доменом. Он выставляет API use-case-ов как набор команд и запросов. Команда или запрос — отдельный тестируемый объект с оформленным входом и понятным результатом; транспорт становится адаптером к этому API.
CQRS как форма Application-слоя
CQRS — Command Query Responsibility Segregation — часто продают как масштабное разделение read и write путей для high-load. Здесь речь не об отдельных базах, event sourcing или сложной распределённой схеме. Работает более простая идея: операции, которые мутируют состояние, отличаются от операций, которые его читают, и код должен это показывать.
Command handler принимает команду и контекст запроса, выполняет use-case и возвращает результат. Query handler принимает запрос, выполняет чтение и возвращает данные. У этих двух сторон разный операционный профиль:
- команды можно ставить в очередь, ретраить, дедуплицировать и аудитить;
- запросы read-only, не меняют состояние и не получают async-семантику команды.
Когда у каждой стороны свой интерфейс и свой tagged locator, фреймворк может обращаться с ними по-разному — без условной логики, размазанной по транспортам и сервисам. Разделение фиксирует не способ запуска, а смысл операции: команда выражает изменение состояния, запрос — чтение.
Анализ: API Platform закрывает транспортный слой
API Platform хорошо решает задачи транспорта. Он описывает операции, строит OpenAPI, управляет content negotiation, связывает ресурсы с state providers и processors, интегрируется с security и rate limits.
Но Provider и Processor сами по себе не образуют API use-case-ов. Это транспортные адаптеры. Они знают, откуда пришёл запрос и куда вернуть результат, но не обязаны задавать командно-запросную модель приложения. Если положить use-case прямо в processor, API Platform снова станет местом, где живёт бизнес-сценарий.
Поэтому границу лучше провести так:
- API Platform остаётся адаптером транспорта;
- каждая операция обращается к команде или запросу Application Layer;
- handler знает use-case и вызывает доменные объекты;
- домен не знает ни об API Platform, ни об HTTP-формате ответа.
Это не запрет на использование API Platform внутри проекта. Это запрет на смешение его транспортной роли с ролью application API.
Решение: Application Layer как архитектурный шов
Application Layer — это не дополнительная папка между контроллером и доменом. Это публичный API приложения для внешних способов запуска. Он отвечает на вопрос: «Какой use-case мы запускаем и какой вход ему нужен?» Транспорт отвечает на другой вопрос: «Как получить этот вход и как вернуть результат конкретному клиенту?»
В этой модели Application Layer представляет use-case через command/query API. Это не требует обязательной инфраструктуры вокруг каждой операции: дополнительные механизмы подключаются только там, где они нужны конкретному сценарию.
Модель выглядит так:
┌─────────────────────────────────────────────────────────────────┐
│ Транспорт │
│ HTTP · API Platform · CLI · Messenger worker · webhook │
│ OpenAPI · security · content negotiation · формат ответа │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer │
│ Command / Query · immutable input · use-case handler │
│ application-level orchestration │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Домен │
│ Агрегаты · инварианты · доменные сервисы · правила состояния │
└─────────────────────────────────────────────────────────────────┘
Диаграмма показывает не последовательность вызовов конкретного бандла, а зависимость ответственности: транспорт не проваливается напрямую в домен, а домен не начинает знать о способе доставки запроса.
Какой контракт нужен приложению
У Application Layer есть три обязательных свойства.
- Оформленный вход. Команда или запрос должны быть объектом, а не массивом, который каждый транспорт собирает по-своему. Это фиксирует вход use-case-а и делает его тестируемым без HTTP.
- Разделённые handler-контракты. Command и Query handler должны быть различимы на уровне API приложения. Тогда неправильный путь можно обнаружить в wiring или при разрешении зависимости, а не после побочного эффекта.
- Единая точка запуска. Транспорт должен передать вход Application Layer, а не сам оркестрировать доменные вызовы. Именно эта точка удерживает одинаковым путь из HTTP, CLI, worker-а и webhook.
Всё остальное подключается только там, где это нужно конкретному use-case-у. Кеширование, идемпотентность, транзакционные границы, авторизация и очереди не становятся автоматически ответственностью каждого handler-а и тем более не должны незаметно появляться в транспортном адаптере.
Где здесь бандл
application-layer-bundle реализует описанный контракт для Symfony: даёт раздельные command/query-интерфейсы, общий вход в Application Layer и интеграцию с транспортными адаптерами. Благодаря этому API Platform может остаться API Platform, а use-case — частью application API.
Бандл не заменяет API Platform, Doctrine или Messenger. Он не проектирует агрегаты, не принимает решения за домен и не превращает CQRS в фреймворк с обязательной инфраструктурой. Его роль — дать повторяемую реализацию шва, который иначе каждая команда собирает вручную.
Подробная реализация вынесена на страницу проекта: там находятся установка, контракты, API, примеры, обработка ошибок, queue-интеграция и тесты. В этой статье достаточно принципа: бандл — решение для явной границы, а не содержание архитектуры.
Когда это оправдано
Application Layer нужен не каждому Symfony-проекту. Обычный CRUD с одним транспортом может спокойно жить в контроллере, если в нём нет самостоятельного use-case API и повторного запуска той же операции.
Отдельная граница окупается, когда:
- один use-case должен работать через несколько транспортов;
- операции записи и чтения требуют разных эксплуатационных правил;
- контроллеры начинают смешивать транспорт, оркестрацию и доменные вызовы;
- проекту нужна явная поверхность application API для независимого тестирования.
Если этих условий нет, команда и handler могут оказаться церемонией ради церемонии. Архитектурный шов нужен там, где через него действительно проходят разные сценарии, а не потому, что слово CQRS хорошо выглядит в README.
Итог
Проблема не в том, что Symfony-контроллер слишком большой. Проблема в том, что транспорт начинает владеть use-case-ом. API Platform эту проблему не решает: он закрывает транспортный слой и оставляет Application Layer проекту.
Рабочее решение — явно разделить три ответственности: транспорт адаптирует внешний вход, Application Layer представляет command/query API, домен хранит правила и инварианты. application-layer-bundle — готовая реализация этого разделения для Symfony. Это не утверждает, что бандл нужен каждому проекту; граница имеет смысл только там, где один use-case действительно живёт за пределами одного HTTP endpoint’а.
Полезные ссылки:
- Application Layer Bundle: реализация, API и тесты
- DDD, Eric Evans — Part III, раздел про изоляцию Application Layer от домена и инфраструктуры
- Implementing DDD, Vaughn Vernon — раздел про Application Services и CQRS Pattern
- API Platform — документация по state providers и processors
- Symfony Messenger — транспорт для асинхронных сообщений
- Reference Symfony service — пример с Application Layer, API Platform, CQRS и persistence
- Демо и пример применения бандла — что демо и пример имплементации или применения бандла можно посмотреть тут, на RU и EN