user@elrise.io:~/projects/$ ← elrise.ru
· [active] теги: phpsymfonydddcqrsapplication-layerbundleapi-platformmessenger

application-layer-bundle — Application Layer в стиле CQRS для Symfony

Бандл Symfony, реализующий границу Application Layer в CQRS-shaped DDD-системе: запрос проходит через sanitize → denormalize DTO → вызов command/query handler → опциональную отправку в очередь.

→ репозиторий

AppLayerBundle — Symfony-бандл, реализующий границу Application Layer в CQRS-shaped DDD-системе. Запрос проходит через sanitize → denormalize DTO → resolve handler → invoke → dispatch; баланс между ответственностями отдан первоклассным интерфейсам CommandHandlerInterface и QueryHandlerInterface.

Бандл транспорт-агностичен: он одинаково работает в обычных Symfony-контроллерах, в state provider'ах и processor'ах API Platform, в Messenger-обработчиках и в консольных командах. Зависит только от symfony/serializer и стандартного сервис-контейнера Symfony; symfony/messenger — опциональная интеграция для асинхронных команд.

Обзор

Архитектурная мотивация и связка с API Platform подробно разобраны в статье CQRS-Application Layer поверх API Platform. Эта страница — справочник по самому бандлу: контракты, компоненты, диспатчеры и сценарии использования.

Главная идея: HTTP-запрос несёт три разные ответственности — транспорт (парсинг, контент-тайп, OpenAPI, rate limits, auth), граница приложения (валидированный payload → use-case input → handler) и домен (агрегаты, репозитории, инварианты). Бандл отвечает за вторую ответственность и оставляет первую — фреймворку/транспорту, третью — domain layer ниже.

Зачем нужен этот бандл

Типичный Symfony-обработчик склеивает три ответственности в одну:

  • парсинг и валидация запроса — транспорт;
  • маппинг валидированного payload во входные данные use-case — application layer;
  • оркестрация use-case против модели предметной области — application/domain.

AppLayerBundle забирает средний слой. Граница между транспортом и use-case — иммутабельный DTO. Сам use-case описан как CommandHandler (мутирует состояние, возвращает результат) или QueryHandler (возвращает read-side данные). Хэндлер может опираться на API Platform, Messenger, собственные репозитории или что угодно ещё — бандл не накладывает никаких ограничений кроме контракта.

Такая форма делает use-case изолированно юнит-тестируемым, делает явным намерение каждой endpoint-операции (command или query) и оставляет транспортный слой (контроллеры, API Platform, RPC, CLI) тонким адаптером.

Ключевые возможности

  • CQRS-контракты: отдельные CommandHandlerInterface и QueryHandlerInterface, регистрируемые через разные tagged-локаторы.
  • Иммутабельная денормализация DTO через symfony/serializer — поддерживаются readonly constructor-promoted DTO и property-only DTO (без setAccessible, актуально для PHP 8.5+).
  • Опциональная санитизация запроса для команд. Query-операции пропускают санитизацию by design — read-side input не мутируется.
  • Синхронная и асинхронная обработка команд через symfony/messenger. MessengerQueueDispatcher подключается автоматически, если пакет установлен; иначе используется NullQueueDispatcher.
  • Расширяемый процессорный пайплайн (DataProcessorInterface) для endpoint'ов без DTO (lookup-таблицы, проекции, вычисляемые представления).
  • Структурированная обработка ошибок через RequestException — единый тип исключения с диагностическим контекстом, оборачивающий все сбои денормализации, локатора и резолва хэндлера.

Архитектура пайплайна

HTTP Request
    │
    ▼
DtoRequestHandler
    │
    ├── sanitize  (RequestSanitizerInterface, только для команд)
    ├── convert   (RequestToDtoConverterInterface → SymfonyDtoDeserializer)
    ├── resolve   (commandLocator | queryLocator)
    ├── invoke    (CommandHandlerInterface.handle | QueryHandlerInterface.handle)
    └── dispatch  (DtoQueueDispatcherInterface, только для команд, async opt-in)

Контракты

  • CommandHandlerInterface — тег app_layer.command_handler. Мутирует состояние и возвращает результат команды (id, presenter, view DTO).
  • QueryHandlerInterface — тег app_layer.query_handler. Возвращает read-side данные без сайд-эффектов.
  • DataProcessorInterface — тег app_layer.data_processor. Используется для endpoint'ов без DTO.
  • DtoDeserializerInterface — абстракция над денормализатором. По умолчанию: SymfonyDtoDeserializer.
  • RequestToDtoConverterInterface — извлекает payload из Symfony Request и превращает его в DTO.
  • RequestSanitizerInterface — опциональная предварительная очистка payload для команд.

Компоненты

  • DtoRequestHandler — оркестратор пайплайна. Две точки входа: dispatchCommand() и dispatchQuery().
  • DefaultRequestToDtoConverter — мерж JSON body или query/form, затем денормализация DTO.
  • SymfonyDtoDeserializer — направляет DTO с конструктором в ObjectNormalizer; property-only DTO обрабатывает прямой рефлексией (без setAccessible).
  • DataProcessor — tagged-локатор для процессорных endpoint'ов.

Диспатчеры очереди

  • DtoQueueDispatcherInterface — абстрактная граница диспатча.
  • MessengerQueueDispatcher — конкретная реализация, активируется при установленном symfony/messenger.
  • NullQueueDispatcher — fallback, когда ни один транспорт не установлен.

Установка и требования

composer require elriseio/application-layer-bundle

Требования

  • PHP 8.3+ с расширениями ctype, curl и json (все три идут в стандартных дистрибутивах PHP; перечислены в require для явности).
  • Symfony 7.2+.

Регистрация бандла:

// config/bundles.php
return [
    // ...
    Elrise\Bundle\AppLayerBundle\AppLayerBundle::class => ['all' => true],
];

Использование

Использование сводится к пяти шагам: определить иммутабельный DTO, реализовать command/query handler, дёрнуть диспатчер из контроллера (или API Platform provider/processor). Для endpoint'ов без DTO — подключить процессорный пайплайн.

DTO (immutable input)

final readonly class CreateOrderCommand
{
    public function __construct(
        public string $customerId,
        public array $items,
    ) {
    }
}

final readonly class ListOrdersQuery
{
    public function __construct(
        public string $customerId,
        public int $limit = 20,
    ) {
    }
}

Command Handler

use Elrise\Bundle\AppLayerBundle\Contract\CommandHandlerInterface;
use Symfony\Component\HttpFoundation\Request;

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()];
    }
}

Query Handler

use Elrise\Bundle\AppLayerBundle\Contract\QueryHandlerInterface;
use Symfony\Component\HttpFoundation\Request;

final class ListOrdersHandler implements QueryHandlerInterface
{
    public function __construct(private OrderRepository $orders) {}

    public function handle(Request $request, object $query): mixed
    {
        \assert($query instanceof ListOrdersQuery);

        return $this->orders->listFor($query->customerId, $query->limit);
    }
}

Вызов из контроллера

final class OrderController
{
    public function __construct(private DtoRequestHandler $handler) {}

    #[Route('/orders', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        $result = $this->handler->dispatchCommand(
            request: $request,
            commandFqcn: CreateOrderCommand::class,
            handlerFqcn: CreateOrderHandler::class,
        );

        return new JsonResponse($result, 201);
    }

    #[Route('/orders', methods: ['GET'])]
    public function list(Request $request): JsonResponse
    {
        $items = $this->handler->dispatchQuery(
            request: $request,
            queryFqcn: ListOrdersQuery::class,
            handlerFqcn: ListOrdersHandler::class,
        );

        return new JsonResponse(['items' => $items]);
    }
}

Для асинхронной отправки передайте dispatchToQueue: true в dispatchCommand. Хэндлер всё равно отрабатывает синхронно (HTTP-ответ остаётся осмысленным), а команда параллельно попадает в настроенный диспатчер (по умолчанию Messenger) для downstream-консьюмеров.

Процессорный пайплайн (без DTO)

use Elrise\Bundle\AppLayerBundle\Contract\DataProcessorInterface;
use Symfony\Component\HttpFoundation\Request;

final class OrderSummaryProcessor implements DataProcessorInterface
{
    public function __construct(private SummaryService $summary) {}

    public function process(Request $request): mixed
    {
        return $this->summary->build();
    }
}

// В контроллере:
$result = $this->dataProcessor->process($request, OrderSummaryProcessor::class);

Интеграция с API Platform

Processor и Provider API Platform естественно ложатся на DtoRequestHandler. Рекомендуемая схема — оставить API Platform чисто транспортным адаптером и делегировать сам use-case в Application Layer.

State Provider для read-endpoint'ов

use ApiPlatform\Metadata\Get;
use ApiPlatform\State\ProviderInterface;
use Elrise\Bundle\AppLayerBundle\Handler\DtoRequestHandler;
use Symfony\Component\HttpFoundation\Request;

final class OrderListProvider implements ProviderInterface
{
    public function __construct(private DtoRequestHandler $handler) {}

    public function provide(Get $operation, array $uriVariables = [], array $context = []): iterable
    {
        $result = $this->handler->dispatchQuery(
            request: Request::createFromGlobals(),
            queryFqcn: ListOrdersQuery::class,
            handlerFqcn: ListOrdersHandler::class,
        );

        return $result;
    }
}

Processor для write-endpoint'ов

use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use Elrise\Bundle\AppLayerBundle\Handler\DtoRequestHandler;
use Symfony\Component\HttpFoundation\Request;

final class CreateOrderProcessor implements ProcessorInterface
{
    public function __construct(private DtoRequestHandler $handler) {}

    public function process(mixed $data, Post $operation, array $uriVariables = [], array $context = []): mixed
    {
        return $this->handler->dispatchCommand(
            request: Request::createFromGlobals(),
            commandFqcn: CreateOrderCommand::class,
            handlerFqcn: CreateOrderHandler::class,
        );
    }
}

Границы остаются явными: API Platform отвечает за OpenAPI, content negotiation, rate limits и форму ответа. Application Layer отвечает за use-case. Агрегаты, репозитории и доменные сервисы живут на уровень ниже и доступны только из command/query handler'ов.

Развёрнутый walkthrough этой интеграции — в статье CQRS-Application Layer поверх API Platform.

Обработка ошибок

Любой сбой денормализации, локатора или резолва хэндлера оборачивается в один и тот же тип исключения со структурированным контекстом:

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,
    );
}

Тот же тип исключения бросается и при ошибках резолва хэндлера (отсутствует FQCN, не тот интерфейс, сервис не зарегистрирован). Это и делает границу application layer наблюдаемой: на транспортном уровне ловится ровно один класс исключений, и он всегда несёт диагностический контекст, достаточный для триажа.

Тестирование

Бандл поставляется с PHPUnit-покрытием оркестратора, денормализатора, локатора и диспатчера очереди:

composer test

Разработка и pre-commit hook

Проект поставляет локальный pre-commit hook, прогоняющий composer check (cs:check + test) — расхождение стиля и регрессии тестов ловятся до push. Hook подключается через core.hooksPath, поэтому действует только внутри этого чекаута.

Установка после клонирования:

./scripts/install-hooks.sh

Это выставляет core.hooksPath в ./.githooks. Дальше hook запускается автоматически перед каждым коммитом; обход — git commit --no-verify, если коммит по делу должен прилететь без повторного прогона (например, ротация composer.lock мейнтейнером).

Статус проекта

Активный. Бандл — это Application Layer, который используется в проде Symfony-проектов в связке с API Platform и Messenger; новые контракты (дополнительные санитайзеры, альтернативные диспатчеры очереди) добавляются по мере появления реальных use-case'ов.

Справочник