- BrainTools - https://www.braintools.ru -
В предыдущей статье [1] я рассказывал, как написал production-ready PHP-роутер Waypoint с помощью ИИ в Cursor IDE. Тогда я проверял гипотезу: можно ли с помощью ИИ создать библиотеку, которую не стыдно выложить на Packagist? Спойлер: получилось.
Сейчас задача масштабнее. Роутер — это один пакет. А что, если нужно собрать полноценное DDD-приложение с CQRS из нескольких пакетов, написать домен с bounded contexts, настроить инфраструктуру, покрыть тестами и пройти PHPStan level 9?
Этот демо-проект я тоже делал в паре с ИИ. Но в этот раз статья не про «ИИ написал код за меня» — она про то, какую архитектуру мы получили и почему она лучше подходит для DDD, чем Symfony + Doctrine.
Я люблю Symfony. Серьёзно. Это прекрасный фреймворк с продуманной архитектурой, мощным DI-контейнером и огромной экосистемой. Я использовал его в продакшене годами и буду рекомендовать для крупных проектов.
Но у меня есть с ним три конкретные проблемы.
Когда мне нужен микросервис с пятью эндпоинтами, я не хочу тянуть за собой 50 МБ вендоров. Когда мне нужен CQRS, я не хочу ставить Symfony Messenger с его транспортами, сериализаторами и конфигурацией на 200 строк YAML.
Принцип инверсии зависимостей (Dependency Inversion Principle) — фундамент чистой архитектуры. Без мощного DI-контейнера с autowiring и autoconfiguration этот принцип превращается в ручное прокидывание зависимостей. Symfony DI — эталон. Но он тянет за собой весь Symfony.
Мне нужен контейнер с тем же набором фич — autowiring, автоконфигурация через атрибуты, компиляция — но как отдельный пакет.
Вот самая болезненная проблема. Если вы практикуете 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;
// ... геттеры, сеттеры, маппинг...
}
А ваша доменная сущность выглядит так:
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 [1], этот проект я делал в 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
Исправлял ошибки [6] 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/"
}
}
}
Главная причина, по которой я люблю Symfony — это его DI-контейнер. Autowiring, autoconfiguration, tagged services, компиляция — всё это позволяет строить архитектуру на принципе инверсии зависимостей, не утопая в конфигурации.
Wirebox даёт ровно то же самое, но в одном пакете на 15 КБ.
$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.
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: 'AppCache',
);
Вот та самая проблема, из-за которой всё началось.
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 для полного контроля.
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, а не сеттеры.
Заказ — более интересный агрегат. Правила жизненного цикла инкапсулированы в 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:
Хендлер помечается доменным атрибутом #[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 — только о домене.
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 я уже подробно писал [1]. Здесь покажу, как он интегрируется в DDD-приложение.
Общая логика [7] контроллеров вынесена в 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-параметры — в аргументы методов.
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, запрещённые переходы статусов. Конечно, я проверял каждый тест и просил доработать покрытие.
Юнит-тесты покрывают домен и хендлеры по отдельности. Но как убедиться, что весь стек работает вместе — контейнер, роутер, 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 базой — полная изоляция без моков.
DDD-проект с двумя bounded contexts и разделением на слои — это красиво на бумаге. Но как гарантировать, что никто случайно не добавит use AppRepository... в доменный слой? Или что OrderDomain не начнёт импортировать классы из ProductDomain?
Для этого в проект добавлен Deptrac [8] — статический анализатор зависимостей между слоями:
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 CoreProductDomain... в CoreOrderDomain — Deptrac сломает CI. Архитектурные границы теперь не соглашение в документации, а автоматическая проверка.
Отдельно хочу рассказать про 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 [9]
Пакеты:
ascetic-soft/wirebox [10] — DI-контейнер
ascetic-soft/rowcast [11] — DataMapper
ascetic-soft/waypoint [12] — PSR-15 роутер
Предыдущая статья:
Автор: kotafey
Источник [13]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/25703
URLs in this post:
[1] предыдущей статье: https://habr.com/ru/articles/996728/
[2] Image: https://sourcecraft.dev/
[3] Wirebox: https://github.com/ascetic-soft/Wirebox
[4] Rowcast: https://github.com/ascetic-soft/Rowcast
[5] Waypoint: https://github.com/ascetic-soft/Waypoint
[6] ошибки: http://www.braintools.ru/article/4192
[7] логика: http://www.braintools.ru/article/7640
[8] Deptrac: https://qossmic.github.io/deptrac/
[9] GitHub: https://github.com/ascetic-soft/BackendDemo
[10] ascetic-soft/wirebox: https://packagist.org/packages/ascetic-soft/wirebox
[11] ascetic-soft/rowcast: https://packagist.org/packages/ascetic-soft/rowcast
[12] ascetic-soft/waypoint: https://packagist.org/packages/ascetic-soft/waypoint
[13] Источник: https://habr.com/ru/articles/996854/?utm_source=habrahabr&utm_medium=rss&utm_campaign=996854
Нажмите здесь для печати.