CQRS Application Layer поверх API Platform
Почему HTTP-обработчик не должен быть use-case-ом и как удержать границу use-case явной, когда API Platform и CQRS делят одну поверхность.
Почему HTTP-обработчик не должен быть use-case-ом и как удержать границу use-case явной, когда API Platform и CQRS делят одну поверхность.
Стандартная форма Symfony-контроллера отождествляет HTTP-запрос с use-case-ом. Он просит у фреймворка тело запроса, валидирует входные данные через атрибуты, мутирует несколько сущностей через репозиторий и возвращает JSON-ответ. Это работает, пока тот же use-case не нужно выставить через другой транспорт — CLI-команду, Messenger-обработчик, вебхук — и бизнес-логику снова приходится отделять от HTTP-обвязки.
Эта статья разбирает небольшой архитектурный сдвиг, который я сделал в нескольких проектах, и Symfony-бандл, который его фиксирует: AppLayerBundle. Сам по себе паттерн не новый — это Application Layer в духе DDD из книг Эванса «Domain-Driven Design» и Вернона «Implementing Domain-Driven Design»; Вернон, в свою очередь, описывает, как этот слой естественно ложится на CQRS-разделение команд и запросов. Интересно здесь другое: конкретный клей с API Platform и те границы, которые удерживают use-case честным, не заставляя каждую конечную точку платить за ceremony, которая ей не нужна.
Каждый входящий запрос в Symfony-приложение несёт три заботы, независимо от фреймворка сверху:
В большинстве Symfony-кода, который я вижу, все три сворачиваются в контроллер. Handler — это use-case. Вызов репозитория — это домен. JSON-сериализатор — это граница. Когда тот же use-case нужен где-то ещё — скажем, из Messenger-консьюмера или CLI-инструмента — логику контроллера приходится снова вытаскивать, и почти всегда с тонким расхождением в поведении.
DDD предлагает решение: ввести Application Layer, который владеет границей между транспортом и доменом. Application Layer выставляет API use-case-ов как набор команд и запросов. Каждая команда или запрос — маленький тестируемый объект, который принимает вход, выполняет use-case и возвращает результат. Транспорт становится тонким адаптером, который превращает HTTP в API приложения.
CQRS — Command Query Responsibility Segregation — часто продают как масштабное разделение read и write путей для highload. Здесь речь не об этом. Здесь работает гораздо более простая идея: операции, которые мутируют состояние, отличаются от операций, которые его читают, и код должен это показывать.
Command handler принимает команду и контекст запроса, выполняет use-case и возвращает результат. Query handler принимает запрос, выполняет чтение и возвращает данные. У этих двух сторон разный операционный профиль:
Когда у каждой стороны свой интерфейс и свой tagged locator, фреймворк может обращаться с ними по-разному — без того, чтобы разводить условную логику по всему коду.
API Platform отлично работает на транспортном уровне. Он генерирует OpenAPI, договаривается о content-type-ах, выставляет операции как state providers и processors, интегрируется с безопасностью и rate limit-ами. Чего он не делает — не определяет самостоятельный use-case API. Providers и processors остаются транспортными адаптерами: их можно переиспользовать между операциями, но command/query границу и контракты handler-ов проект всё равно задаёт отдельно.
Чистое разделение — оставить API Platform транспортом и маршрутизировать каждую операцию к команде или запросу в Application Layer. State provider становится таким:
final class OrderListProvider implements ProviderInterface
{
public function __construct(private DtoRequestHandler $handler) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$result = $this->handler->dispatchQuery(
request: Request::createFromGlobals(),
queryFqcn: ListOrdersQuery::class,
handlerFqcn: ListOrdersHandler::class,
);
return $result;
}
}
Processor для write-операции имеет ту же форму, но вызывает dispatchCommand. API Platform по-прежнему владеет OpenAPI, content negotiation и безопасностью. Application Layer владеет use-case-ом. Домен доступен только через Application Layer.
Application Layer в форме CQRS требует трёх вещей:
CommandHandlerInterface и QueryHandlerInterface тегируются по-разному, чтобы контейнер маршрутизировал, а фреймворк применял разные операционные правила.Эти три части укладываются в слоёный пирог, который выглядит так. Каждый слой говорит только со слоем прямо под ним — application layer и есть шов между транспортом и доменом.
┌─────────────────────────────────────────────────────────────────┐
│ Транспорт │
│ HTTP · OpenAPI · content negotiation · rate limits · auth │
│ API Platform · Symfony-контроллер · CLI · Messenger worker │
└─────────────────────────────────────────────────────────────────┘
│ request ──▶ DTO FQCN + handler FQCN
▼
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer (AppLayerBundle) │
│ sanitize ─▶ denormalize ─▶ resolve ─▶ invoke ─▶ dispatch │
│ CommandHandlerInterface · QueryHandlerInterface │
└─────────────────────────────────────────────────────────────────┘
│ command / query
▼
┌─────────────────────────────────────────────────────────────────┐
│ Домен │
│ Агрегаты · репозитории · доменные сервисы · инварианты │
│ Достижим только через application layer │
└─────────────────────────────────────────────────────────────────┘
Вот минимальная форма, на которой я остановился. Она намеренно маленькая: каждая дополнительная ответственность (кеширование, idempotency keys, границы транзакций) — явный opt-in.
final readonly class CreateOrderCommand
{
public function __construct(
public string $customerId,
public array $items,
) {}
}
final class CreateOrderHandler implements CommandHandlerInterface
{
public function __construct(private OrderRepository $orders) {}
public function handle(Request $request, object $command): mixed
{
\assert($command instanceof CreateOrderCommand);
$order = $this->orders->create($command);
return ['id' => $order->id()];
}
}
Оркестратор — это граница. В Symfony-терминах это точка входа, в которую вызывает контроллер — или, в случае API Platform, processor.
AppLayerBundle (github.com/elriseio/application-layer-bundle) реализует этот основной CQRS-путь. Это не фреймворк поверх API Platform; это небольшой конвейер между транспортом и use-case-ом:
HTTP Request
│
▼
┌──────────────────┐
│ dispatchCommand │ dispatchQuery (без sanitize, без queue)
└────────┬─────────┘
│
┌────────▼─────────┐ только команды
│ sanitize │ через RequestSanitizerInterface
└────────┬─────────┘
│
┌────────▼─────────┐
│ convert ─▶ DTO │ через SymfonyDtoDeserializer
└────────┬─────────┘
│
┌────────▼─────────┐
│ resolve handler │ tagged locator
│ │ (command_handler | query_handler)
└────────┬─────────┘
│
┌────────▼─────────┐
│ handler.handle │ возвращает результат (id, view, data)
└────────┬─────────┘
│
┌────────▼─────────┐ команды + dispatchToQueue = true
│ dispatch │ через MessengerQueueDispatcher
└──────────────────┘
symfony/serializer. Readonly DTO с promoted-свойствами поддерживаются нативно. DTO только со свойствами обрабатываются прямой рефлексией, без ReflectionProperty::setAccessible() (deprecated в PHP 8.5).symfony/messenger; иначе используется null dispatcher.Для endpoint-ов без DTO у бандла есть отдельный DataProcessor; основной command/query путь выше от этого не меняется.
Здесь нет автоматического ретрая, кеша, event sourcing, фабрики агрегатов. Эти вещи живут выше границы приложения, не внутри неё. Всё, что каждой команде нужно — валидация, авторизация, идемпотентность — лучше выражается middleware-ом на use-case-е или декоратором над handler-ом.
Одна из самых важных деталей — как бандл сообщает об ошибках. Ошибка денормализации — не программистская ошибка, а runtime-условие, вызванное внешней системой или непослушным клиентом. Ошибки разбора входа, денормализации и резолвинга handler-а бандл приводит к RequestException со структурированным контекстом:
try {
$object = $this->serializer->denormalize($data, $type, null, $context);
} catch (\Throwable $e) {
throw new RequestException(
sprintf('Failed to denormalize DTO "%s": %s', $type, $e->getMessage()),
['type' => $type, 'data_keys' => array_keys($data)],
0,
$e,
);
}
Ошибки резолвинга handler-а — когда переданный FQCN не реализует ожидаемый интерфейс или сервис не зарегистрирован — дают то же исключение с той же формой. Ошибки самого use-case-а и инфраструктуры намеренно не нормализуются: транспортный слой ловит RequestException рядом с доменными и инфраструктурными исключениями, а не вместо них.
Async-диспатч — место, где большинство реализаций Application Layer перегибают. Искушение — предположить, что каждая команда в конце концов должна встать в очередь, и впечь семантику очереди в оркестратор. Это ошибка: синхронный путь — default, а очередь — opt-in для тех случаев, где она реально оправдана.
Бандл держит очередь явным выбором на каждом вызове:
$result = $this->handler->dispatchCommand(
request: $request,
commandFqcn: CreateOrderCommand::class,
handlerFqcn: CreateOrderHandler::class,
dispatchToQueue: true,
);
Когда dispatchToQueue равен true, handler выполняется синхронно, чтобы HTTP-ответ мог нести осмысленный результат, а затем команда передаётся настроенному dispatcher-у. Это сохраняет downstream-диспатч опциональным, но не служит подтверждением доставки: без Messenger null dispatcher просто пропускает шаг очереди.
Первая версия бандла использовала единый RequestHandlerInterface и для команд, и для запросов. В спеке это выглядело DRY, но схлопывало именно то различие, которое Application Layer был призван сохранять. Handler, который и читает, и пишет — это сервис, а не use-case. Разделение контракта было изменением в 30 строк, которое окупалось каждый раз, когда операционные правила должны были разойтись.
Второй урок был про денормализацию DTO. Первая реализация для DTO без конструктора полагалась на setAccessible(true). Начиная с PHP 8.1 этот вызов уже не влияет на доступ к свойствам, а в PHP 8.5 он deprecated. Текущий путь записывает значения прямой рефлексией; DTO с конструктором по-прежнему проходят через ObjectNormalizer, и обе ветки делят одну обработку ошибок.
Третий урок был про compiler passes. В первом срезе кастомный DataProcessorPass собирал locator для тега процессора параллельно декларативной конфигурации. Удаление pass-а убрало процедурное дублирование и оставило wiring выраженным через tagged locator.
Паттерн Application Layer не бесплатен. Каждая команда и запрос — класс. Каждый handler — класс. Оверхед оправдан, когда у проекта больше горстки нетривиальных use-case-ов, когда ту же логику нужно выставить больше чем через один транспорт, или когда команде нужно явное command/query разделение по операционным причинам.
Для CRUD на четыре эндпоинта над одной сущностью обычный контроллер — нормально. Церемония команды, handler-а, интерфейса и тега не оправдана простотой use-case-а. Бандл не претендует на роль default-а — он там, где default перестаёт справляться.
Код, на который ссылается эта статья, лежит в github.com/elriseio/application-layer-bundle. Бандл распространяется под MIT, рассчитан на PHP 8.3+ и Symfony 7.2+; Messenger остаётся опциональной интеграцией. PHPUnit покрывает оркестратор, денормалайзер, locator и queue dispatcher. README проходит по тем же паттернам, что и здесь, с примерами для обычных контроллеров и API Platform processors. Пакет также доступен на Packagist: elriseio/application-layer-bundle.
Полный справочник по проекту — контракты, компоненты, диспатчеры, связка с API Platform, обработка ошибок и интеграция с Messenger — лежит на странице проекта: application-layer-bundle.