DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ. autowiring.. autowiring. mapping.. autowiring. mapping. orm.. autowiring. mapping. orm. PHP.. autowiring. mapping. orm. PHP. ИИ.

Предисловие

В предыдущей статье я рассказывал, как написал production-ready PHP-роутер Waypoint с помощью ИИ в Cursor IDE. Тогда я проверял гипотезу: можно ли с помощью ИИ создать библиотеку, которую не стыдно выложить на Packagist? Спойлер: получилось.

Сейчас задача масштабнее. Роутер — это один пакет. А что, если нужно собрать полноценное DDD-приложение с CQRS из нескольких пакетов, написать домен с bounded contexts, настроить инфраструктуру, покрыть тестами и пройти PHPStan level 9?

Этот демо-проект я тоже делал в паре с ИИ. Но в этот раз статья не про «ИИ написал код за меня» — она про то, какую архитектуру мы получили и почему она лучше подходит для DDD, чем Symfony + Doctrine.

Зачем мне это: три боли Symfony-разработчика

Я люблю Symfony. Серьёзно. Это прекрасный фреймворк с продуманной архитектурой, мощным DI-контейнером и огромной экосистемой. Я использовал его в продакшене годами и буду рекомендовать для крупных проектов.

Но у меня есть с ним три конкретные проблемы.

Боль 1: он слишком тяжёлый

Когда мне нужен микросервис с пятью эндпоинтами, я не хочу тянуть за собой 50 МБ вендоров. Когда мне нужен CQRS, я не хочу ставить Symfony Messenger с его транспортами, сериализаторами и конфигурацией на 200 строк YAML.

Боль 2: мне нужен сильный DI-контейнер

Принцип инверсии зависимостей (Dependency Inversion Principle) — фундамент чистой архитектуры. Без мощного DI-контейнера с autowiring и autoconfiguration этот принцип превращается в ручное прокидывание зависимостей. Symfony DI — эталон. Но он тянет за собой весь Symfony.

Мне нужен контейнер с тем же набором фич — autowiring, автоконфигурация через атрибуты, компиляция — но как отдельный пакет.

Боль 3: Doctrine и DDD — это двойной маппинг

Вот самая болезненная проблема. Если вы практикуете DDD с Symfony, то наверняка сталкивались с этим:

// Doctrine Entity — это не ваша доменная сущность.
// Это класс, который Doctrine хочет видеть.

#[ORMEntity]
#[ORMTable(name: 'products')]
class ProductEntity
{
    #[ORMId]
    #[ORMColumn(type: 'guid')]
    private string $id;

    #[ORMColumn(length: 255)]
    private string $name;

    #[ORMColumn(type: 'integer')]
    private int $priceAmount;

    #[ORMColumn(length: 3)]
    private string $priceCurrency;

    // ... геттеры, сеттеры, маппинг...
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 1

А ваша доменная сущность выглядит так:

final class Product
{
    private function __construct(
        private readonly ProductId $id,
        private ProductName $name,
        private Money $price,
    ) {}
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 2

И теперь вы пишете код для маппинга ProductEntity -> Product и обратно. Двойная работа. Два набора классов. Два набора тестов. И постоянный вопрос: «А стоит ли DDD таких накладных расходов?»

Нет, не стоит. Не DDD виноват — виноват инструмент. Doctrine ORM спроектирован как слой абстракции над базой данных с Identity Map, Unit of Work, Change Tracking, Proxy Objects. Всё это прекрасно для CRUD, но мешает DDD.

Мне нужен инструмент, который берёт строку из БД и позволяет самому собрать доменную сущность — через DTO или напрямую из массива. Без прокси-объектов. Без магии.

Три пакета вместо фреймворка

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

Пакет

Назначение

Аналог в Symfony

Wirebox

DI-контейнер с autowiring и autoconfiguration

Symfony DependencyInjection

Rowcast

DataMapper + QueryBuilder поверх PDO

Doctrine DBAL + частично ORM

Waypoint

PSR-15 р��утер с атрибутами

Symfony Routing + HttpKernel

Все три пакета:

  • Работают на PHP 8.4+

  • Не имеют зависимостей друг на друга

  • Следуют PSR-стандартам (PSR-7, PSR-11, PSR-15)

  • Покрыты тестами и PHPStan level 9

Чтобы доказать, что этот стек работает для реального DDD-приложения, я сделал демо-проект: E-commerce с каталогом товаров и заказами, CQRS, двумя bounded contexts и полной инфраструктурой.

Как это делалось: ИИ как архитектурный партнёр

Как и Waypoint, этот проект я делал в Cursor IDE с ИИ-ассистентом. Но если роутер — это одна библиотека с чёткой спецификацией, то здесь задача другого масштаба: архитектура DDD-приложения, два bounded context, CQRS, инфраструктурный слой, 60+ файлов.

Вот как распределились роли:

Я решал:

  • Какую предметную область взять (E-commerce: товары + заказы)

  • Как разделить домен и инфраструктуру (два неймспейса: Core и App)

  • Какие bounded contexts выделить и как они взаимодействуют

  • Как организовать CQRS через атрибуты с автоконфигурацией

  • Какие Value Objects нужны и какие инварианты они защищают

ИИ делал:

  • Генерировал файлы по заданной архитектуре

  • Писал реализации репозиториев, контроллеров, middleware

  • Создавал 62 юнит-теста и 30 интеграционных тестов

  • Настраивал PHPStan, PHP CS Fixer, PHPUnit, Deptrac

  • Исправлял ошибки PHPStan level 9 (а их было немало)

  • Реализовал авторезолв команд из __invoke() и AbstractController

Главный инсайт: ИИ отлично справляется с имплементацией, когда ты чётко описал архитектуру. Но архитектурные решения — это ответственность разработчика. ИИ не скажет: «Тебе нужны атрибуты с #[AutoconfigureTag], чтобы CQRS заработал через tagged services декларативно». Это нужно знать самому.

Архитектура: два неймспейса, чёткая граница

Структура проекта построена по принципу DDD с физическим разделением слоёв:

BackendDemo/
├── core/                    # Домен (namespace Core)
│   ├── SharedKernel/
│   │   ├── CQRS/            # Атрибуты и интерфейсы Command/Query
│   │   └── ValueObject/     # Базовые Value Objects
│   ├── Product/
│   │   ├── Domain/          # Сущности, VO, интерфейсы репозиториев
│   │   └── Application/     # Команды, запросы, хендлеры, DTO
│   └── Order/
│       ├── Domain/
│       └── Application/
├── src/                     # Инфраструктура (namespace App)
│   ├── Repository/          # Реализации репозиториев (Rowcast)
│   ├── Http/Controller/     # AbstractController + контроллеры (Waypoint)
│   ├── Http/Middleware/      # PSR-15 middleware
│   ├── CQRS/                # CommandBus, QueryBus (Wirebox)
│   └── Kernel.php           # Bootstrap приложения
├── tests/
│   ├── Unit/                # 62 юнит-теста (домен, хендлеры)
│   └── Integration/         # 30 интеграционных тестов (full stack + SQLite)
└── deptrac.yaml             # Контроль архитектурных зависимостей
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 3

Ключевой принцип: папка core/ не зависит от инфраструктуры. Ноль внешних зависимостей — только стандартные классы PHP (Attribute, DateTimeImmutable, InvalidArgumentException). Домен — это чистый PHP: классы, value objects, атрибуты. Хендлеры зависят только от доменных интерфейсов и атрибутов, определённых в SharedKernel.

Конфигурация autoload в composer.json:

{
    "autoload": {
        "psr-4": {
            "Core\": "core/",
            "App\": "src/"
        }
    }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 4

Wirebox: DI-контейнер с характером Symfony

Главная причина, по которой я люблю Symfony — это его DI-контейнер. Autowiring, autoconfiguration, tagged services, компиляция — всё это позволяет строить архитектуру на принципе инверсии зависимостей, не утопая в конфигурации.

Wirebox даёт ровно то же самое, но в одном пакете на 15 КБ.

Autowiring и сканирование директорий

$builder = new ContainerBuilder(projectDir: __DIR__);

// Сканируем все классы — они автоматически регистрируются
$builder->scan(__DIR__ . '/src');
$builder->scan(__DIR__ . '/core');

// Биндим интерфейсы к реализациям
$builder->bind(ProductRepositoryInterface::class, ProductRepository::class);
$builder->bind(OrderRepositoryInterface::class, OrderRepository::class);

$container = $builder->build();
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 5

Всё. Контейнер нашёл все классы, прочитал конструкторы, построил граф зависимостей. Никакого YAML, никаких XML.

Autoconfiguration: CQRS через атрибуты

CQRS-атрибуты живут в доменном слое и не зависят от инфраструктуры — это чистый PHP:

// core/SharedKernel/CQRS/AsCommandHandler.php
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class AsCommandHandler
{
    /**
     * @param class-string|null $command The command class (resolved from __invoke if omitted)
     */
    public function __construct(
        public ?string $command = null,
    ) {}
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 6

Атрибут #[AsQueryHandler] устроен аналогично.

Класс команды указывать явно не нужно — он автоматически определяется по первому параметру метода __invoke():

#[AsCommandHandler]
final readonly class CreateProductHandler
{
    public function __invoke(CreateProduct $command): void { ... }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 7

А как Wirebox узнаёт, что классы с #[AsCommandHandler] нужно тегировать? Через registerForAutoconfiguration() в Kernel:

// src/Kernel.php
$builder->registerForAutoconfiguration(AsCommandHandler::class)->tag('command.handler');
$builder->registerForAutoconfiguration(AsQueryHandler::class)->tag('query.handler');
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 8

При scan() Wirebox видит, что класс помечен #[AsCommandHandler], и автоматически тегирует его как command.handler. При первом dispatch() шина смотрит на тип первого параметра __invoke() и строит карту маршрутизации. Ноль дублирования — класс команды указан ровно один раз: в сигнатуре хендлера.

При желании можно указать класс явно — #[AsCommandHandler(CreateProduct::class)] — но по умолчанию это не требуется.

Важный результат: в core/ нет ни одного use на внешний пакет. Домен — чистый PHP. Связь с DI-контейнером — исключительно через конфигурацию в Kernel.

Компиляция для продакшена

Для продакшена контейнер можно скомпилировать в PHP-файл — ноль рефлексии в рантайме, всё через OPcache:

$builder->compile(
    outputPath: __DIR__ . '/var/cache/CompiledContainer.php',
    className: 'CompiledContainer',
    namespace: 'AppCache',
);
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 9

Rowcast: маппинг из БД напрямую, без Doctrine

Вот та самая проблема, из-за которой всё началось.

Rowcast — это обёртка над PDO с DataMapper и QueryBuilder. Он даёт два уровня работы: через DataMapper с автогидрацией в DTO или через Connection с прямым SQL. Для простых сущностей удобен первый подход — DTO для чтения из таблицы products:

final class ProductDTO
{
    public string $id;
    public string $name;
    public int $priceAmount;
    public string $priceCurrency;
    public string $description;
    public DateTimeImmutable $createdAt;
    public DateTimeImmutable $updatedAt;
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 10

Никаких аннотаций маппинга, никаких атрибутов ORM. Просто публичные свойства с типами. Rowcast использует Reflection для гидрации и автоматически кастит типы: строки из БД превращаются в int, DateTimeImmutable, BackedEnum.

Два подхода к маппингу

Rowcast даёт свободу выбора: использовать DataMapper с DTO или работать с Connection напрямую. В проекте оба подхода сосуществуют.

Подход 1: DataMapper + DTO (для простых агрегатов)

ProductRepository использует DataMapper с ResultSetMapping — Rowcast гидрирует строку из БД в DTO, а репозиторий собирает доменную сущность:

final readonly class ProductRepository implements ProductRepositoryInterface
{
    private DataMapper $mapper;

    public function __construct(private Connection $connection)
    {
        $this->mapper = new DataMapper($this->connection);
    }

    public function findById(ProductId $id): ?Product
    {
        $dto = $this->mapper->findOne($rsm, ['id' => $id->value]);

        return $dto !== null ? self::toDomain($dto) : null;
    }

    private static function toDomain(ProductDTO $dto): Product
    {
        return Product::reconstitute(
            id: new ProductId($dto->id),
            name: new ProductName($dto->name),
            price: new Money($dto->priceAmount, $dto->priceCurrency),
            description: $dto->description,
            createdAt: $dto->createdAt,
            updatedAt: $dto->updatedAt,
        );
    }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 11

Подход 2: прямой SQL (для сложных агрегатов)

OrderRepository работает с Connection напрямую — без DataMapper, без промежуточных DTO. Запись — через executeStatement(), чтение — через fetchAssociative() с ручной гидрацией в домен:

final readonly class OrderRepository implements OrderRepositoryInterface
{
    public function __construct(
        private Connection $connection,
    ) {}

    public function save(Order $order): void
    {
        $this->connection->transactional(function (Connection $conn) use ($order): void {
            // ...
            $total = $order->getTotal();
            $this->connection->executeStatement(
                'INSERT INTO orders (id, status, customer_name, total_amount, total_currency, created_at, updated_at)
                 VALUES (?, ?, ?, ?, ?, ?, ?)',
                [$order->getId()->value, $order->getStatus()->value, ...],
            );
        });
    }

    private function hydrateOrder(array $row): Order
    {
        $lineRows = $this->connection->fetchAllAssociative(
            'SELECT * FROM order_lines WHERE order_id = ? ORDER BY position ASC',
            [$row['id']],
        );

        $lines = array_map(static fn(array $r) => new OrderLine(
            productId: new ProductId($r['product_id']),
            productName: $r['product_name'],
            unitPrice: new Money($r['unit_price_amount'], $r['unit_price_currency']),
            quantity: $r['quantity'],
        ), $lineRows);

        return Order::reconstitute(
            id: new OrderId($row['id']),
            status: OrderStatus::from($row['status']),
            customerName: $row['customer_name'],
            lines: $lines,
            createdAt: new DateTimeImmutable($row['created_at']),
            updatedAt: new DateTimeImmutable($row['updated_at']),
        );
    }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 12

Никаких промежуточных Row-объектов. Строка из БД → массив → доменная сущность с Value Objects. Для агрегата с дочерними сущностями (Order + OrderLine) это самый прямой путь.

Нет прокси-объектов. Нет ленивой загрузки. Нет магии. Вы полностью контролируете, как данные из БД превращаются в доменную модель.

Сравнение с Doctrine + DDD:

Doctrine ORM

Rowcast

Простые сущности

БД → ORM Entity → Domain (двойной маппинг)

БД → DTO → Domain (DataMapper)

Сложные агрегаты

БД → ORM Entity → Domain (двойной маппинг)

БД → Domain напрямую (SQL)

Зависимость от ORM

Да (аннотации, прокси)

Нет (plain PHP)

Identity Map, Unit of Work

Да (часто мешает в DDD)

Нет (вы контролируете)

С Doctrine у вас всегда два слоя маппинга. С Rowcast — выбираете сами: DTO для удобства или прямой SQL для полного контроля.

Доменный слой: чистый PHP, без компромиссов

Product — агрегат с Value Objects

final class Product
{
    private function __construct(
        private readonly ProductId $id,
        private ProductName $name,
        private Money $price,
        private string $description,
        private readonly DateTimeImmutable $createdAt,
        private DateTimeImmutable $updatedAt,
    ) {}

    public static function create(
        ProductId $id,
        ProductName $name,
        Money $price,
        string $description = '',
    ): self {
        $now = new DateTimeImmutable();
        return new self($id, $name, $price, $description, $now, $now);
    }

    public static function reconstitute(
        ProductId $id, ProductName $name, Money $price,
        string $description, DateTimeImmutable $createdAt,
        DateTimeImmutable $updatedAt,
    ): self {
        return new self($id, $name, $price, $description, $createdAt, $updatedAt);
    }

    public function rename(ProductName $name): void
    {
        $this->name = $name;
        $this->touch();
    }

    public function changePrice(Money $price): void
    {
        $this->price = $price;
        $this->touch();
    }

    // ...
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 13

Конструктор — private. Два способа создания: create() для нового объекта и reconstitute() для восстановления из персистентности. Методы изменения состояния — бизнес-операции с Value Objects, а не сеттеры.

Order — конечный автомат с переходами состояний

Заказ — более интересный агрегат. Правила жизненного цикла инкапсулированы в BackedEnum:

enum OrderStatus: string
{
    case Pending = 'pending';
    case Confirmed = 'confirmed';
    case Cancelled = 'cancelled';
    case Completed = 'completed';

    public function canTransitionTo(self $target): bool
    {
        return match ($this) {
            self::Pending   => in_array($target, [self::Confirmed, self::Cancelled], true),
            self::Confirmed => in_array($target, [self::Completed, self::Cancelled], true),
            self::Cancelled, self::Completed => false,
        };
    }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 14

Агрегат Order использует их для валидации:

private function transitionTo(OrderStatus $target): void
{
    if (!$this->status->canTransitionTo($target)) {
        throw new LogicException(
            sprintf('Cannot transition order from "%s" to "%s".',
                $this->status->value, $target->value),
        );
    }

    $this->status = $target;
    $this->updatedAt = new DateTimeImmutable();
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 15

Попробуйте закомплитить отменённый заказ — получите LogicException. Бизнес-правила защищены на уровне домена, а не контроллера.

CQRS: автоматическая маршрутизация через атрибуты

Схема CQRS:

  1. Хендлер помечается доменным атрибутом #[AsCommandHandler] или #[AsQueryHandler]

  2. В Kernel через registerForAutoconfiguration() атрибуты связываются с тегами — при scan() Wirebox автоматически тегирует все помеченные классы. Интерфейсы с множеством реализаций исключаются из auto-binding через excludeFromAutoBinding()

  3. CommandBus и QueryBus получают теговые сервисы через $container->getTagged()

  4. Маршрутизация команда → хендлер происходит автоматически по типу первого параметра __invoke()

Хендлер — просто класс с атрибутом

#[AsCommandHandler]
final readonly class CreateProductHandler
{
    public function __construct(
        private ProductRepositoryInterface $repository,
    ) {}

    public function __invoke(CreateProduct $command): void
    {
        $product = Product::create(
            id: ProductId::generate(),
            name: new ProductName($command->name),
            price: new Money($command->priceAmount, $command->priceCurrency),
            description: $command->description,
        );

        $this->repository->save($product);
    }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 16

Атрибут #[AsCommandHandler] тегирует хендлер для DI-контейнера. Класс команды определяется автоматически из типа параметра __invoke(CreateProduct $command) — никаких object $command и assert(), никакого дублирования имени класса. Autowiring разрешает зависимости через биндинг из Kernel. Хендлер ничего не знает о БД, роутере или HTTP — только о домене.

CommandBus — маршрутизация через атрибуты

final class CommandBus
{
    private ?array $handlerMap = null;

    public function __construct(private readonly Container $container) {}

    public function dispatch(CommandInterface $command): void
    {
        $handler = $this->resolveHandler($command);
        ($handler)($command);
    }

    private function getHandlerMap(): array
    {
        if ($this->handlerMap !== null) {
            return $this->handlerMap;
        }

        $this->handlerMap = [];

        foreach ($this->container->getTagged('command.handler') as $handler) {
            $commandClass = self::extractCommandClass($handler);

            if ($commandClass !== null) {
                $this->handlerMap[$commandClass] = $handler;
            }
        }

        return $this->handlerMap;
    }

    private static function extractCommandClass(object $handler): ?string
    {
        $ref = new ReflectionClass($handler);
        $attrs = $ref->getAttributes(AsCommandHandler::class);

        if ($attrs === []) {
            return null;
        }

        $attr = $attrs[0]->newInstance();

        // Если класс указан явно — используем его, иначе определяем из __invoke()
        return $attr->command ?? self::resolveCommandFromInvoke($ref);
    }

    private static function resolveCommandFromInvoke(ReflectionClass $ref): ?string
    {
        if (!$ref->hasMethod('__invoke')) {
            return null;
        }

        $type = $ref->getMethod('__invoke')->getParameters()[0]?->getType();

        return $type instanceof ReflectionNamedType && !$type->isBuiltin()
            ? $type->getName()
            : null;
    }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 17

При первом dispatch() билдится карта CommandClass → Handler. Для каждого хендлера шина сначала проверяет атрибут — если класс команды указан явно, используется он. Если нет — Reflection читает тип первого параметра __invoke(). Повторные вызовы отдают из кэша. Дублирование хендлеров для одной команды ловится исключением — fail fast.

Никакой конфигурации — поставил атрибут на хендлер, он заработал.

Waypoint: роутинг через атрибуты

О Waypoint я уже подробно писал. Здесь покажу, как он интегрируется в DDD-приложение.

Общая логика контроллеров вынесена в AbstractController — JSON-ответы, парсинг тела запроса, пагинация:

abstract readonly class AbstractController
{
    public function __construct(
        protected CommandBus $commandBus,
        protected QueryBus $queryBus,
    ) {}

    protected static function json(int $status, array $data): ResponseInterface { ... }
    protected static function parseBody(ServerRequestInterface $request): array { ... }
    protected static function str(array $body, string $key, string $default = ''): string { ... }
    protected static function int(array $body, string $key, int $default = 0): int { ... }
    protected static function pagination(ServerRequestInterface $request, int $defaultLimit = 50): array { ... }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 18

Конкретные контроллеры тонкие — только маршрутизация HTTP в команды и запросы:

#[Route('/api/products')]
final readonly class ProductController extends AbstractController
{
    #[Route(methods: ['GET'], name: 'products.list')]
    public function list(ServerRequestInterface $request): ResponseInterface
    {
        [$limit, $offset] = self::pagination($request);

        $products = $this->queryBus->dispatch(new ListProducts($limit, $offset));

        return self::json(200, array_map(self::serializeProduct(...), $products));
    }

    #[Route('/{id}', methods: ['GET'], name: 'products.show')]
    public function show(string $id): ResponseInterface
    {
        $product = $this->queryBus->dispatch(new GetProduct($id));
        return self::json(200, self::serializeProduct($product));
    }

    #[Route(methods: ['POST'], name: 'products.create')]
    public function create(ServerRequestInterface $request): ResponseInterface
    {
        $body = self::parseBody($request);

        $this->commandBus->dispatch(new CreateProduct(
            name: self::str($body, 'name'),
            priceAmount: self::int($body, 'price_amount'),
            priceCurrency: self::str($body, 'price_currency', 'USD'),
            description: self::str($body, 'description'),
        ));

        return self::json(201, ['message' => 'Product created.']);
    }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 19

Контроллер тонкий: принял HTTP-запрос, собрал команду/запрос, отдал в шину, сериализовал ответ. Вся логика — в домене. Waypoint автоматически инжектит зависимости из контейнера в конструктор, route-параметры — в аргументы методов.

Bootstrap: минималистичный Kernel

final class Kernel
{
    public function boot(): Container
    {
        $builder = new ContainerBuilder(projectDir: $this->projectDir);

        // Интерфейсы с множеством реализаций — исключаем из auto-binding,
        // чтобы избежать ошибок неоднозначной привязки при сканировании
        $builder->excludeFromAutoBinding(
            MiddlewareInterface::class,
            CommandInterface::class,
            QueryInterface::class,
        );

        // CQRS: тегируем хендлеры по их доменным атрибутам
        $builder->registerForAutoconfiguration(AsCommandHandler::class)->tag('command.handler');
        $builder->registerForAutoconfiguration(AsQueryHandler::class)->tag('query.handler');

        // Сканируем инфраструктуру и домен
        $builder->scan($this->projectDir . '/src');
        $builder->scan($this->projectDir . '/core');

        // Фабрика подключения к БД
        $builder->register(Connection::class, static function (Container $c): Connection {
            // ... параметры из .env
        });

        return $builder->build();
    }

    public function getRouter(): Router
    {
        $router = new Router($this->boot());
        $router->addMiddleware(ErrorHandlerMiddleware::class);
        $router->addMiddleware(JsonResponseMiddleware::class);
        $router->loadAttributes(ProductController::class, OrderController::class);

        return $router;
    }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 20

Вся конфигурация CQRS — две строки: registerForAutoconfiguration(AsCommandHandler::class)->tag('command.handler') и аналогичная для AsQueryHandler. Wirebox при scan() видит, что класс помечен доменным атрибутом, и автоматически тегирует его. Интерфейсы с множеством реализаций (MiddlewareInterface, CommandInterface, QueryInterface) исключены из auto-binding через excludeFromAutoBinding() — это подавляет ошибку неоднозначной привязки без лишних правил автоконфигурации. Биндинг интерфейсов к реализациям тоже автоматический — Wirebox находит единственную реализацию ProductRepositoryInterface и привязывает к ней.

Сравните с config/services.yaml + config/routes.yaml + config/packages/*.yaml в Symfony. Здесь вся конфигурация — PHP-код с автокомплитом и type-safety.

Тестируемость: домен без моков инфраструктуры

Доменный слой тестируется без единой зависимости на инфраструктуру:

final class OrderTest extends TestCase
{
    #[Test]
    public function itCalculatesTotal(): void
    {
        $order = $this->placeOrder();
        $total = $order->getTotal();

        // Line 1: 1000 * 2 = 2000, Line 2: 500 * 3 = 1500
        self::assertSame(3500, $total->amount);
        self::assertSame('USD', $total->currency);
    }

    #[Test]
    public function itCannotCompletePendingOrder(): void
    {
        $order = $this->placeOrder();

        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Cannot transition');

        $order->complete();
    }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 21

Хендлеры тестируются с моком репозитория:

final class PlaceOrderHandlerTest extends TestCase
{
    #[Test]
    public function itPlacesAnOrder(): void
    {
        $productId = ProductId::generate();

        $product = Product::create(
            $productId,
            new ProductName('Widget'),
            new Money(1000, 'USD'),
        );

        $productRepo = $this->createMock(ProductRepositoryInterface::class);
        $productRepo->method('findById')->willReturn($product);

        $orderRepo = $this->createMock(OrderRepositoryInterface::class);
        $orderRepo->expects($this->once())
            ->method('save')
            ->with(self::callback(static function (Order $order): bool {
                return $order->getStatus() === OrderStatus::Pending
                    && $order->getCustomerName() === 'Alice'
                    && count($order->getLines()) === 1
                    && $order->getTotal()->amount === 2000;
            }));

        $handler = new PlaceOrderHandler($orderRepo, $productRepo);

        ($handler)(new PlaceOrder(
            customerName: 'Alice',
            lines: [
                new PlaceOrderLine(productId: $productId->value, quantity: 2),
            ],
        ));
    }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 22

62 юнит-теста, 106 ассертов, PHPStan level 9 — ноль ошибок. ИИ сгенерировал тесты, включая граничные случаи: пустые имена, отрицательные цены, невалидные UUID, запрещённые переходы статусов. Конечно, я проверял каждый тест и просил доработать покрытие.

Интеграционные тесты: полный стек на SQLite

Юнит-тесты покрывают домен и хендлеры по отдельности. Но как убедиться, что весь стек работает вместе — контейнер, роутер, middleware, шины, репозитории, база?

Для этого добавлены интеграционные тесты, которые поднимают полное приложение с SQLite in-memory вместо MySQL. TestKernel повторяет конфигурацию продакшен-ядра, но подменяет подключение к БД:

final class TestKernel
{
    public function boot(): Container
    {
        $builder = new ContainerBuilder(projectDir: $this->projectDir);

        // Те же исключения из auto-binding, что и в продакшене
        $builder->excludeFromAutoBinding(
            MiddlewareInterface::class,
            CommandInterface::class,
            QueryInterface::class,
        );

        $builder->scan($this->projectDir . '/src');
        $builder->scan($this->projectDir . '/core');

        // Подмена БД — SQLite in-memory вместо MySQL
        $builder->register(Connection::class, static function (): Connection {
            return Connection::create('sqlite::memory:', nestTransactions: true);
        });

        return $builder->build();
    }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 23

Базовый класс IntegrationTestCase предоставляет HTTP-хелперы для отправки запросов через роутер:

abstract class IntegrationTestCase extends TestCase
{
    protected Router $router;
    protected Connection $connection;

    protected function setUp(): void
    {
        $kernel = new TestKernel($this->projectDir);
        $this->connection = $kernel->boot()->get(Connection::class);
        $this->createSchema();
        $this->router = $kernel->getRouter();
    }

    protected function get(string $uri, array $queryParams = []): ResponseInterface { ... }
    protected function post(string $uri, array $body = []): ResponseInterface { ... }
    protected function put(string $uri, array $body = []): ResponseInterface { ... }
    protected function json(ResponseInterface $response): array { ... }
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 24

Тесты проверяют полный цикл от HTTP-запроса до базы данных:

#[Test]
public function createProductPersistsToDatabase(): void
{
    $this->post('/api/products', [
        'name' => 'Gadget',
        'price_amount' => 4500,
        'price_currency' => 'EUR',
        'description' => 'A fancy gadget',
    ]);

    $response = $this->get('/api/products');
    $products = $this->json($response);

    self::assertCount(1, $products);
    self::assertSame('Gadget', $products[0]['name']);
    self::assertSame(4500, $products[0]['price']['amount']);
}
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 25

30 интеграционных тестов покрывают все эндпоинты: CRUD продуктов, создание и отмену заказов, пагинацию, обработку ошибок (404, невалидные данные). Каждый тест стартует с чистой in-memory базой — полная изоляция без моков.

Deptrac: архитектурные границы под контролем

DDD-проект с двумя bounded contexts и разделением на слои — это красиво на бумаге. Но как гарантировать, что никто случайно не добавит use AppRepository... в доменный слой? Или что OrderDomain не начнёт импортировать классы из ProductDomain?

Для этого в проект добавлен Deptrac — статический анализатор зависимостей между слоями:

deptrac:
  layers:
    - name: SharedKernel
      collectors:
        - { type: classNameRegex, value: '#^Core\SharedKernel\#' }
    - name: ProductDomain
      collectors:
        - { type: classNameRegex, value: '#^Core\Product\Domain\#' }
    - name: ProductApplication
      collectors:
        - { type: classNameRegex, value: '#^Core\Product\Application\#' }
    - name: OrderDomain
      collectors:
        - { type: classNameRegex, value: '#^Core\Order\Domain\#' }
    - name: OrderApplication
      collectors:
        - { type: classNameRegex, value: '#^Core\Order\Application\#' }
    - name: Infrastructure
      collectors:
        - { type: classNameRegex, value: '#^App\#' }

  ruleset:
    SharedKernel: ~                                    # Ни от кого не зависит
    ProductDomain: [SharedKernel]                      # Домен → только SharedKernel
    OrderDomain: [SharedKernel]                        # Домен → только SharedKernel
    ProductApplication: [ProductDomain, SharedKernel]  # Application → свой Domain
    OrderApplication: [OrderDomain, SharedKernel]      # Application → свой Domain
    Infrastructure: [ProductApplication, ProductDomain, # Инфраструктура → всё
                     OrderApplication, OrderDomain, SharedKernel]
DDD и CQRS на PHP без Symfony: собираем легковесный стек с помощью ИИ - 26

Семь слоёв, строгие правила:

  • SharedKernel — базовый слой без внешних зависимостей

  • Domain слои зависят только от SharedKernel (не друг от друга!)

  • Application слои зависят от своего Domain и SharedKernel

  • Infrastructure может зависеть от всех слоёв core

Попробуйте добавить use CoreProductDomain... в CoreOrderDomain — Deptrac сломает CI. Архитектурные границы теперь не соглашение в документации, а автоматическая проверка.

ИИ и PHPStan level 9: история в 30 ошибках

Отдельно хочу рассказать про PHPStan level 9. Это максимальный уровень строгости, и он не прощает ничего.

Первый прогон PHPStan после генерации кода выдал 30 ошибок. Типичные проблемы:

  • Cannot cast mixed to string — PHPStan level 9 запрещает (string) на mixed. Нужно сначала сузить тип через is_string() или @var

  • Unsafe usage of new static() — в абстрактных классах нужен final конструктор

  • Template type resolution — Rowcast использует generic-типы, и PHPStan не может их вывести через ResultSetMapping

ИИ итеративно исправлял ошибки, но не всегда с первого раза. Например, он предложил заменить (string) на strval(), но PHPStan level 9 не принимает mixed и в strval(). Пришлось перейти на @var аннотации с явным указанием типа из БД.

Это хороший пример того, где ИИ нуждается в контроле: он знает синтаксис PHP, но не всегда понимает нюансы конкретного уровня PHPStan.

Масштаб проекта

Метрика

Значение

Файлов в core/

37

Файлов в src/

10

Тестовых файлов

15 (11 юнит + 4 интеграционных)

Юнит-тестов / ассертов

62 / 106

Интеграционных тестов / ассертов

30 / 94

Всего тестов / ассертов

92 / 200

PHPStan

Level 9, 0 errors

Deptrac

0 violations

PHP CS Fixer

0 fixable issues

Bounded contexts

2 (Product, Order)

CQRS handlers

8 (4 commands, 4 queries)

Что мы получили

Symfony + Doctrine

Наш стек

DI-контейнер

Symfony DI (~2 МБ)

Wirebox (~15 КБ)

ORM/DataMapper

Doctrine ORM (~8 МБ)

Rowcast (~20 КБ)

Роутинг

Symfony Router + HttpKernel

Waypoint (~25 КБ)

Autowiring

Да

Да

Autoconfiguration

Да

Да

Компиляция контейнера

Да

Да

Атрибуты роутинга

Да

Да

PSR-совместимость

Частично

PSR-7, PSR-11, PSR-15

vendor/

~50 МБ

~5 МБ

Когда это не подходит

Будем честны. Этот стек — не замена Symfony:

  • Нет встроенного ORM с Identity Map и Unit of Work (это осознанное решение)

  • Нет Security-компонента, Form-компонента, Twig

  • Нет экосистемы бандлов

  • Нет Symfony Messenger с поддержкой AMQP/Redis транспортов

  • Нет Doctrine Migrations

Если вам нужен полноценный монолит с админкой, авторизацией, очередями и фоновыми задачами — берите Symfony.

Но если вам нужен:

  • Микросервис с чистой доменной моделью

  • API с CQRS и строгой типизацией

  • Легковесное приложение, где вы контролируете каждый слой

  • DDD без накладных расходов Doctrine ORM

…то этот стек даст вам всё, что нужно.

ИИ как инструмент: выводы

С помощью ИИ можно реализовывать невероятные архитектурные решения за считанные часы. Выстроить с нуля DDD-приложение с двумя bounded contexts, CQRS, 40+ файлами, 92 тестами и PHPStan level 9 — в одиночку это было бы просто невозможно сделать за разумное время. С ИИ — одна сессия.

Ключевое условие: вы должны знать, чего хотите. ИИ блестяще реализует архитектуру, когда вы чётко её описали. Разделение bounded contexts, декларативная автоконфигурация через атрибуты, CQRS через tagged services — всё это ИИ превратит в работающий код. Но идея, направление и контроль качества — за вами.

Результат: 60+ файлов, 92 теста (62 юнит + 30 интеграционных), два bounded context, полный CQRS, PHPStan level 9 с нулём ошибок, Deptrac без нарушений — и всё это с доменом без единой внешней зависимости.

Итого

Три пакета. Один принцип: делай одну вещь, делай её хорошо.

  • Wirebox даёт мощный DI с autowiring и autoconfiguration — как в Symfony, но без всего остального

  • Rowcast даёт маппинг из БД в DTO или напрямую в домен — без Doctrine, без прокси, без магии

  • Waypoint даёт PSR-15 роутинг с атрибутами — как в Symfony, но без HttpKernel

Все три — PSR-совместимые, покрыты тестами, работают с PHPStan level 9.

Исходный код демо-проекта: GitHub


Пакеты:

Предыдущая статья:

Автор: kotafey

Источник

Rambler's Top100