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