Предисловие
В предыдущей статье я рассказывал, как написал 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 хочет видеть. #[ORM\Entity] #[ORM\Table(name: 'products')] class ProductEntity { #[ORM\Id] #[ORM\Column(type: 'guid')] private string $id; #[ORM\Column(length: 255)] private string $name; #[ORM\Column(type: 'integer')] private int $priceAmount; #[ORM\Column(length: 3)] private string $priceCurrency; // ... геттеры, сеттеры, маппинг... }
А ваша доменная сущность выглядит так:
final class Product { private function __construct( private readonly ProductId $id, private ProductName $name, private Money $price, ) {} }
И теперь вы пишете код для маппинга ProductEntity -> Product и обратно. Двойная работа. Два набора классов. Два набора тестов. И постоянный вопрос: «А стоит ли DDD таких накладных расходов?»
Нет, не стоит. Не DDD виноват — виноват инструмент. Doctrine ORM спроектирован как слой абстракции над базой данных с Identity Map, Unit of Work, Change Tracking, Proxy Objects. Всё это прекрасно для CRUD, но мешает DDD.
Мне нужен инструмент, который берёт строку из БД и позволяет самому собрать доменную сущность — через DTO или напрямую из массива. Без прокси-объектов. Без магии.
Три пакета вместо фреймворка
Для решения этих проблем я собрал три пакета, каждый из которых решает одну конкретную задачу:
Все три пакета:
Работают на 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 # Контроль архитектурных зависимостей
Ключевой принцип: папка core/ не зависит от инфраструктуры. Ноль внешних зависимостей — только стандартные классы PHP (Attribute, DateTimeImmutable, InvalidArgumentException). Домен — это чистый PHP: классы, value objects, атрибуты. Хендлеры зависят только от доменных интерфейсов и атрибутов, определённых в SharedKernel.
Конфигурация autoload в composer.json:
{ "autoload": { "psr-4": { "Core\\": "core/", "App\\": "src/" } } }
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();
Всё. Контейнер нашёл все классы, прочитал конструкторы, построил граф зависимостей. Никакого 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, ) {} }
Атрибут #[AsQueryHandler] устроен аналогично.
Класс команды указывать явно не нужно — он автоматически определяется по первому параметру метода __invoke():
#[AsCommandHandler] final readonly class CreateProductHandler { public function __invoke(CreateProduct $command): void { ... } }
А как Wirebox узнаёт, что классы с #[AsCommandHandler] нужно тегировать? Через registerForAutoconfiguration() в Kernel:
// src/Kernel.php $builder->registerForAutoconfiguration(AsCommandHandler::class)->tag('command.handler'); $builder->registerForAutoconfiguration(AsQueryHandler::class)->tag('query.handler');
При 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: 'App\Cache', );
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; }
Никаких аннотаций маппинга, никаких атрибутов 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, ); } }
Подход 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']), ); } }
Никаких промежуточных 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(); } // ... }
Конструктор — 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, }; } }
Агрегат 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(); }
Попробуйте закомплитить отменённый заказ — получите LogicException. Бизнес-правила защищены на уровне домена, а не контроллера.
CQRS: автоматическая маршрутизация через атрибуты
Схема CQRS:
Хендлер помечается доменным атрибутом
#[AsCommandHandler]или#[AsQueryHandler]В Kernel через
registerForAutoconfiguration()атрибуты связываются с тегами — приscan()Wirebox автоматически тегирует все помеченные классы. Интерфейсы с множеством реализаций исключаются из auto-binding черезexcludeFromAutoBinding()CommandBusиQueryBusполучают теговые сервисы через$container->getTagged()Маршрутизация команда → хендлер происходит автоматически по типу первого параметра
__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); } }
Атрибут #[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; } }
При первом 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 { ... } }
Конкретные контроллеры тонкие — только маршрутизация 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.']); } }
Контроллер тонкий: принял 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; } }
Вся конфигурация 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(); } }
Хендлеры тестируются с моком репозитория:
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), ], )); } }
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(); } }
Базовый класс 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 { ... } }
Тесты проверяют полный цикл от 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']); }
30 интеграционных тестов покрывают все эндпоинты: CRUD продуктов, создание и отмену заказов, пагинацию, обработку ошибок (404, невалидные данные). Каждый тест стартует с чистой in-memory базой — полная изоляция без моков.
Deptrac: архитектурные границы под контролем
DDD-проект с двумя bounded contexts и разделением на слои — это красиво на бумаге. Но как гарантировать, что никто случайно не добавит use App\Repository\... в доменный слой? Или что Order\Domain не начнёт импортировать классы из Product\Domain?
Для этого в проект добавлен 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]
Семь слоёв, строгие правила:
SharedKernel — базовый слой без внешних зависимостей
Domain слои зависят только от SharedKernel (не друг от друга!)
Application слои зависят от своего Domain и SharedKernel
Infrastructure может зависеть от всех слоёв core
Попробуйте добавить use Core\Product\Domain\... в Core\Order\Domain\ — Deptrac сломает CI. Архитектурные границы теперь не соглашение в документации, а автоматическая проверка.
ИИ и PHPStan level 9: история в 30 ошибках
Отдельно хочу рассказать про PHPStan level 9. Это максимальный уровень строгости, и он не прощает ничего.
Первый прогон PHPStan после генерации кода выдал 30 ошибок. Типичные проблемы:
Cannot cast mixed to string— PHPStan level 9 запрещает(string)наmixed. Нужно сначала сузить тип черезis_string()или@varUnsafe 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
Пакеты:
ascetic-soft/wirebox — DI-контейнер
ascetic-soft/rowcast — DataMapper
ascetic-soft/waypoint — PSR-15 роутер
Предыдущая статья:
