CQRS Application Layer поверх API Platform
Почему HTTP-обработчик не должен быть use-case-ом и как удержать границу приложения явной между API Platform и доменом.
Почему HTTP-обработчик не должен быть use-case-ом и как удержать границу приложения явной между 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 и тесты находятся на странице проекта бандла и в репозитории.
У входящего запроса есть три разных аспекта:
| Аспект | Ответственность |
|---|---|
| Транспорт | 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 — Command Query Responsibility Segregation — часто продают как масштабное разделение read и write путей для high-load. Здесь речь не об отдельных базах, event sourcing или сложной распределённой схеме. Работает более простая идея: операции, которые мутируют состояние, отличаются от операций, которые его читают, и код должен это показывать.
Command handler принимает команду и контекст запроса, выполняет use-case и возвращает результат. Query handler принимает запрос, выполняет чтение и возвращает данные. У этих двух сторон разный операционный профиль:
Когда у каждой стороны свой интерфейс и свой tagged locator, фреймворк может обращаться с ними по-разному — без условной логики, размазанной по транспортам и сервисам. Разделение фиксирует не способ запуска, а смысл операции: команда выражает изменение состояния, запрос — чтение.
API Platform хорошо решает задачи транспорта. Он описывает операции, строит OpenAPI, управляет content negotiation, связывает ресурсы с state providers и processors, интегрируется с security и rate limits.
Но Provider и Processor сами по себе не образуют API use-case-ов. Это транспортные адаптеры. Они знают, откуда пришёл запрос и куда вернуть результат, но не обязаны задавать командно-запросную модель приложения. Если положить use-case прямо в processor, API Platform снова станет местом, где живёт бизнес-сценарий.
Поэтому границу лучше провести так:
Это не запрет на использование API Platform внутри проекта. Это запрет на смешение его транспортной роли с ролью application API.
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-у. Кеширование, идемпотентность, транзакционные границы, авторизация и очереди не становятся автоматически ответственностью каждого 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 и повторного запуска той же операции.
Отдельная граница окупается, когда:
Если этих условий нет, команда и handler могут оказаться церемонией ради церемонии. Архитектурный шов нужен там, где через него действительно проходят разные сценарии, а не потому, что слово CQRS хорошо выглядит в README.
Проблема не в том, что Symfony-контроллер слишком большой. Проблема в том, что транспорт начинает владеть use-case-ом. API Platform эту проблему не решает: он закрывает транспортный слой и оставляет Application Layer проекту.
Рабочее решение — явно разделить три ответственности: транспорт адаптирует внешний вход, Application Layer представляет command/query API, домен хранит правила и инварианты. application-layer-bundle — готовая реализация этого разделения для Symfony. Это не утверждает, что бандл нужен каждому проекту; граница имеет смысл только там, где один use-case действительно живёт за пределами одного HTTP endpoint'а.
Полезные ссылки: