user@elrise.io:~/articles/$ ← elrise.ru
· 11 min phpsymfonydddcqrsapi-platformarchitecture

CQRS Application Layer поверх API Platform

Почему 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-приложение несёт три заботы, независимо от фреймворка сверху:

  • Транспорт. Парсинг HTTP-тела, content negotiation, rate limits, аутентификация, OpenAPI-метаданные. Это та часть, которую API Platform делает хорошо, а обычный контроллер — плохо.
  • Граница приложения. Преобразование валидированной нагрузки во вход use-case-а, решение «это команда или запрос», маршрутизация к нужному handler-у, сквозные вещи вроде авторизации и идемпотентности.
  • Домен. Агрегаты, доменные сервисы, репозитории, доменные события. Часть, которой полезнее всего быть закрытой от транспорта и application-слоя.

В большинстве Symfony-кода, который я вижу, все три сворачиваются в контроллер. Handler — это use-case. Вызов репозитория — это домен. JSON-сериализатор — это граница. Когда тот же use-case нужен где-то ещё — скажем, из Messenger-консьюмера или CLI-инструмента — логику контроллера приходится снова вытаскивать, и почти всегда с тонким расхождением в поведении.

DDD предлагает решение: ввести Application Layer, который владеет границей между транспортом и доменом. Application Layer выставляет API use-case-ов как набор команд и запросов. Каждая команда или запрос — маленький тестируемый объект, который принимает вход, выполняет use-case и возвращает результат. Транспорт становится тонким адаптером, который превращает HTTP в API приложения.

CQRS как форма Application-слоя

CQRS — Command Query Responsibility Segregation — часто продают как масштабное разделение read и write путей для highload. Здесь речь не об этом. Здесь работает гораздо более простая идея: операции, которые мутируют состояние, отличаются от операций, которые его читают, и код должен это показывать.

Command handler принимает команду и контекст запроса, выполняет use-case и возвращает результат. Query handler принимает запрос, выполняет чтение и возвращает данные. У этих двух сторон разный операционный профиль:

  • Команды можно ставить в очередь, ретраить, дедуплицировать, аудитить.
  • Запросы read-only, идемпотентны и никогда не диспатчатся в очередь.

Когда у каждой стороны свой интерфейс и свой tagged locator, фреймворк может обращаться с ними по-разному — без того, чтобы разводить условную логику по всему коду.

Почему API Platform это не покрывает

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-слоя

Application Layer в форме CQRS требует трёх вещей:

  1. Неизменяемый входной объект для use-case-а. Команда или запрос. Readonly DTO с конструктором и promoted properties хорошо работают на PHP 8.3+, который требует текущая версия бандла.
  2. Явный интерфейс handler-а. CommandHandlerInterface и QueryHandlerInterface тегируются по-разному, чтобы контейнер маршрутизировал, а фреймворк применял разные операционные правила.
  3. Оркестратор, который принимает HTTP-запрос, строит вход, резолвит handler и вызывает его. Здесь живут санитизация, денормализация и диспатч в очередь.

Эти три части укладываются в слоёный пирог, который выглядит так. Каждый слой говорит только со слоем прямо под ним — 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
  └──────────────────┘
  1. Запускает санитизацию запроса для команд. По умолчанию это no-op hook; запросы пропускают его намеренно, чтобы read-side вход не мутировался.
  2. Денормализует DTO через symfony/serializer. Readonly DTO с promoted-свойствами поддерживаются нативно. DTO только со свойствами обрабатываются прямой рефлексией, без ReflectionProperty::setAccessible() (deprecated в PHP 8.5).
  3. Резолвит handler через tagged locator. Command и query handlers тегируются раздельно, чтобы оркестратор мог отказаться диспатчить команду через query handler (и наоборот) в runtime.
  4. Опционально диспатчит команду в очередь после того, как синхронный handler вернёт результат. Интеграция подключается, только когда установлен 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 без сочленения

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.