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 — подключить процессорный пайплайн.
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);
Обработка ошибок
Любой сбой денормализации, локатора или резолва хэндлера оборачивается в один и тот же тип исключения со структурированным контекстом:
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'ов.