Бизнес живет годами. Фреймворки приходят и уходят.

Фреймворки приходят и уходят. Помните symfony 1, Zend Framework или CodeIgniter? Многие бизнесы, которые начинали на них, работают и сегодня. Их правила не особо изменились. Продукту все так же нужна цена, а заказ все так же должен быть оплачен. Но код вокруг этих правил переписывался снова и снова.

А теперь подумайте о собственном проекте. Если бы завтра вам пришлось перейти на новый фреймворк, какую часть бизнес-логики вам пришлось бы переписать? Если ответ — «большую часть», значит, ваши бизнес-правила намертво повенчаны с фреймворком.

Domain Driven Design (DDD) решает эту проблему. Ваши бизнес-правила живут в чистом PHP, в своем собственном слое. Symfony и Doctrine остаются снаружи, как заменяемые инструменты. Меняйте фреймворк — ядро бизнеса этого даже не заметит.

Почему DDD становится еще важнее с приходом ИИ-ассистентов

До появления больших языковых моделей (LLM) у DDD была одна большая цена: лишний код. Value-объекты, репозитории, команды, маппинги. Многие команды говорили: «слишком много кода для простой фичи», и отказывались от него.

Сегодня такие инструменты, как Claude Code или Codex, делают генерацию кода гораздо более эффективной и экономят нам кучу времени. Написание кода больше не является узким горлышком. Но кодинг всегда был лишь одной из частей жизненного цикла разработки ПО. Мы по-прежнему занимаемся сбором требований, планированием, моделированием систем и проектированием архитектуры. И кто-то все еще должен читать, ревьюить и поддерживать сгенерированный код.

Поэтому вопрос изменился. Теперь он звучит не как «насколько быстро мы можем написать код?». Он звучит так: «насколько чист и читаем этот код и насколько точно он описывает бизнес?» В этой области DDD сложно превзойти. Его строгие правила даже помогают ИИ-ассистенту: понятные имена, которым нужно следовать, четкие границы, которые нужно соблюдать, и агрегаты, защищающие бизнес-правила от небрежного кода. Вот почему DDD заслуживает места в вашем следующем крупном проекте на Symfony.

Для кого эта статья?

Эта статья предназначена для Symfony-разработчиков, которые уже знакомы с основами DDD. Мы сосредоточимся на  реализации DDD в Symfony, попутно давая краткие определения. Если вы новичок в теории, отличным началом будет книга «Domain-Driven Design in PHP» (Buenosvinos, Soronellas, Akbary).

В моей предыдущей статье, Mastering Symfony Service Container: Modern PHP Attributes Edition, мы создали CreateProductService. В этом руководстве мы сделаем следующий шаг. Мы пройдем по одному пользовательскому сценарию:  создадим продукт, зададим ему цену, опубликуем его, позволим клиентам оставлять отзывы и сообщим об этом остальной части магазина. Когда этот путь завершится, завершится и статья.

Во всех примерах используются  PHP 8.5,  Symfony 8.1,  Doctrine ORM 3.7 и  DoctrineBundle 3. Здесь мы показываем только ключевой код.

С чего начинается DDD: разговор с доменным экспертом

Каждый DDD-проект начинается с разговора, а не с кода. Перед тем как написать первую строчку, мы пообщались с  контент-менеджером нашего магазина. Он не пишет код, но знает каждое правило каталога. И он записал их для нас:

u1YSIcYRhAalHZNACGBg13TnBdAz1yWD8rbdndBW.webp

Domain Driven Design начинается с доменного эксперта: каждое бизнес-правило в его блокноте становится строительным блоком DDD в нашем коде на Symfony.

Синие заметки — наши. Обратите внимание на еще одну деталь: в его блокноте нет ни слова о Symfony, базах данных или фреймворках. Только бизнес. Мы будем постоянно возвращаться к этой странице на нашем пути.

Говорите на его языке: Единый язык (Ubiquitous Language)

Посмотрите на обведенную заметку: «Пожалуйста, используйте МОИ слова в коде!» Это самое сердце DDD.

Когда наш контент-менеджер говорит «опубликовать продукт», наш код должен говорить:

// ❌ Developer language
$product->setStatus(2);
 
// ✅ Business language
$product->publish();

В DDD этот общий язык между разработчиками и представителями бизнеса называется  Единым языком (Ubiquitous Language). Одни и те же слова на встречах, в тасках и в коде. Когда он читает $product->addReview(), он понимает, о чем речь. Никакого перевода не требуется.

Планируем магазин: разделение на ограниченные контексты (Bounded Contexts)

Наш контент-менеджер не видит один большой магазин. Он видит четыре команды. Мы разделим наш код точно так же:

  •  Catalog: продукты, цены, отзывы
  •  Inventory: остатки, склады
  •  Orders: корзины, заказы
  •  Users: клиенты, адреса

Зачем делить? Он сам сказал лучше всего: «Моя команда определяет цену. Склад определяет остатки. Не смешивайте!» Футболка в контексте  Catalog имеет название, цену и отзывы. Та же футболка в контексте  Inventory имеет уровень запасов и полку на складе. Объединение этого в одну модель привело бы к хаосу.

Каждая часть владеет собственной моделью и собственным языком. В DDD такая часть называется  ограниченным контекстом (bounded context).

Теперь посмотрите на небольшой набросок на его странице. Catalog сообщает Inventory, когда продукт опубликован. Catalog передает в Orders цену. Users передает в Orders покупателя. Эта схема того, кто с кем общается, называется  картой контекстов (context map).

В Symfony каждый ограниченный контекст получает свою собственную папку внутри src/, и каждый из них делится на три слоя:

src/
├── Shared/
└── Catalog/
    ├── Domain/          the business rules, pure PHP
    ├── Application/     the use cases
    └── Infrastructure/  Symfony, Doctrine, controllers

Мы будем заполнять эти папки одну за другой. Наш путь начинается в  Catalog, а в конце мы отправим сообщение в  Inventory.

Давайте начнем с самого сердца приложения: с Домена (Domain).

Domain (Домен)

Домен — это место, где живут наши бизнес-правила. Чистый PHP. Никакого Symfony, никакой Doctrine. Если вы завтра удалите Symfony, этот код все равно будет работать.

Создаем модель продукта

Он рассказал нам, как живет продукт: новый продукт всегда является  черновиком (draft), затем он  публикуется (published), а  отмененный (cancelled) продукт мертв. Давайте перенесем это в код. Продукт — это  Сущность (Entity). У него есть идентичность, которая никогда не меняется, даже если меняется его название или цена.

Идентификатор продукта нужен ему сразу же, например, для загрузки фото. Поэтому мы генерируем ID сами, в виде UUID, а не полагаемся на автоинкремент в базе данных. Мы знаем ID  до того, как сохраним продукт. Позже вы увидите еще одну причину для этого.

namespace App\Catalog\Domain\Model\Product;
 
final readonly class ProductId
{
    public function __construct(public string $value) {}
 
    public function __toString(): string
    {
        return $this->value;
    }
}
 
enum ProductStatus: string
{
    case Draft = 'draft';
    case Published = 'published';
    case Cancelled = 'cancelled';
}namespace App\Catalog\Domain\Model\Product;
 
final class Product
{
    public private(set) ProductStatus $status;
    public private(set) \DateTimeImmutable $createdAt;
    public private(set) ?\DateTimeImmutable $publishedAt = null;
 
    public function __construct(
        public private(set) ProductId $id,
        public private(set) string $title,
        public private(set) string $description,
        public private(set) int $price,
        public private(set) string $image,
    ) {
        $this->status = ProductStatus::Draft;
        $this->createdAt = new \DateTimeImmutable();
    }
}

Вот и всё! Несколько моментов, на которые стоит обратить внимание:

  •  public private(set) — это фича PHP 8.4. Любой может  прочитать $product->title, но только сам Product может его  изменить. Никаких геттеров и сеттеров.
  • Новый продукт  всегда создается в статусе Draft. Вызывающий код не может это решить. Это решает модель.
  • Никаких use Symfony…, никаких use Doctrine\ORM…. Только чистый PHP.
  • Класс объявлен как final, и Doctrine с этим согласна. Начиная с PHP 8.4 Doctrine использует  нативные lazy-объекты вместо сгенерированных прокси-классов, поэтому ей не нужно наследоваться от нашей сущности.

 Примечание: Вы можете подумать: «Подождите, int $price? А как же валюта? Что если цена отрицательная?» Отличный вопрос! Это наш следующий шаг.

Защищаем цену с помощью Value-объекта

Наш контент-менеджер выразился ясно: цена — это не просто число. Это  сумма плюс  валюта, и она никогда не может быть отрицательной. Если мы будем использовать int $price, каждому сервису придется помнить об этих правилах.

 Value-объект (Value Object) хранит их в одном месте. У него нет идентичности, и он никогда не меняется после создания.

namespace App\Catalog\Domain\Model\Product;
 
enum Currency: string
{
    case USD = 'USD';
    case EUR = 'EUR';
    case BDT = 'BDT';
 
    public static function fromCode(string $code): self
    {
        return self::tryFrom(strtoupper($code))
            ?? throw new \InvalidArgumentException("Currency $code is not supported.");
    }
}
 
final readonly class ProductPrice
{
    public function __construct(
        public int $amount, // in cents, never float for money
        public Currency $currency,
    ) {
        if ($amount < 0) {
            throw new \InvalidArgumentException('Price can not be negative.');
        }
    }
 
    #[\NoDiscard]
    public function withAmount(int $amount): self
    {
        return new self($amount, $this->currency);
    }
}

Теперь наш Product использует его:

// ❌ Before
public private(set) int $price,
 
// ✅ After
public private(set) ProductPrice $price,

Почему это лучше:

  •  Всегда валиден: отрицательную цену просто невозможно создать.
  •  В одном месте: правила живут в ProductPrice, а не в каждом отдельном сервисе.
  •  Безопасно передавать: он никогда не меняется. Метод withAmount() возвращает  новую цену, а атрибут #[\NoDiscard] (PHP 8.5) предупредит вас, если вы забудете ее использовать.

Предотвращаем некорректные данные с помощью валидации

Цена защищает себя сама. Но у него есть правила и для самого объявления:

  • Название не должно быть пустым и должно содержать не более 100 символов.
  • Описание не должно быть пустым.
  • Изображение должно быть URL-адресом или путем к файлу.

Мы добавляем небольшие проверки в конструктор:

public function __construct(/* ... */)
{
    $this->assertValidTitle($title);
    // assertValidDescription(), assertValidImage() ...
 
    $this->status = ProductStatus::Draft;
    $this->createdAt = new \DateTimeImmutable();
}
 
private function assertValidTitle(string $title): void
{
    if (trim($title) === '' || mb_strlen($title) > 100) {
        throw new \InvalidArgumentException('Title must be 1 to 100 characters.');
    }
}

Symfony Validator по-прежнему отлично подходит для проверки ввода из форм. Но только Домен может  гарантировать, что Product всегда валиден, откуда бы он ни создавался: из API, формы, консольной команды или скрипта импорта.

Наш продукт валиден. Теперь давайте наделим его реальным поведением.

Публикация и отмена продукта с помощью корня агрегата (Aggregate Root)

Давайте посмотрим на типичный сервис из хорошо написанного Symfony-приложения:

// ❌ Business rules live in the service
class PublishProductService
{
    public function publish(Product $product): void
    {
        if ($product->getStatus() !== 'draft') {
            throw new \DomainException('Only draft products can be published.');
        }
 
        $product->setStatus('published');
        $product->setPublishedAt(new \DateTimeImmutable());
        $this->repository->save($product);
    }
}

С этим кодом все в порядке. Но Product здесь — просто мешок с сеттерами. Завтра какой-нибудь ImportProductService вызовет setStatus('published') и забудет про проверку. Правило будет нарушено, и никто этого не заметит.

Вспомните схему статусов на его странице: отмененный продукт больше никогда не может быть опубликован. Красный крест говорит сам за себя. Давайте перенесем это правило туда, где ему и место: внутрь Product.

// ✅ The Product protects its own rules
public function publish(): void
{
    if ($this->status !== ProductStatus::Draft) {
        throw new \DomainException('Only a draft product can be published.');
    }
 
    $this->status = ProductStatus::Published;
    $this->publishedAt = new \DateTimeImmutable();
}
 
public function cancel(): void
{
    if ($this->status === ProductStatus::Cancelled) {
        throw new \DomainException('Product is already cancelled.');
    }
 
    $this->status = ProductStatus::Cancelled;
}

Вот и всё! Теперь есть  только один способ опубликовать продукт, и он всегда проверяет это правило. Ни один сервис не сможет об этом забыть.

Это называется  богатой моделью предметной области (Rich Domain Model). Противоположность — класс только с геттерами и сеттерами — называется  анемичной моделью (Anemic Domain Model).

Наш Product также является  Корнем агрегата (Aggregate Root). Агрегат — это группа объектов, которые изменяются и сохраняются вместе. Корень — это единственная дверь в эту группу. Давайте посмотрим, что находится внутри нашей группы.

Добавление отзыва и расчет среднего рейтинга

Клиенты могут оставлять отзывы только на  опубликованный продукт, а расчет  среднего рейтинга должен быть быстрым. Он даже подсказал нам, как: хранить количество и сумму. Отзыв существует только внутри продукта, поэтому Product сам создает его. Никто другой не вызывает new Review().

Сначала крошечный Value-объект для рейтинга:

final readonly class Rating
{
    public function __construct(public int $value)
    {
        if ($value < 1 || $value > 5) {
            throw new \InvalidArgumentException('Rating must be between 1 and 5.');
        }
    }
}

Теперь Product:

public private(set) int $reviewCount = 0;
public private(set) int $ratingSum = 0;
private Collection $reviews;
private int $version = 1;
 
public function addReview(string $customerId, Rating $rating, string $comment): void
{
    if ($this->status !== ProductStatus::Published) {
        throw new \DomainException('Only a published product can be reviewed.');
    }
 
    $this->reviews->add(new Review($this, $customerId, $rating->value, $comment));
    $this->reviewCount++;
    $this->ratingSum += $rating->value;
}
 
public function averageRating(): float
{
    return $this->reviewCount === 0 ? 0.0 : round($this->ratingSum / $this->reviewCount, 1);
}

Почему это лучше:

  •  Быстро: мы никогда не перебираем в цикле 10 000 отзывов. Мы просто поддерживаем актуальность reviewCount и ratingSum.
  •  Всегда корректно: среднее значение не может рассинхронизироваться, потому что только addReview() изменяет эти числа.
  •  Клиент по ID: $customerId — это просто строка. Клиенты живут в контексте Users, поэтому мы храним только их ID.

 Примечание: Вы можете подумать: «А как же Черная пятница? Что если придет два отзыва в одну и ту же секунду?» Отличный вопрос! Для этого и нужен $version. Doctrine использует его для  оптимистической блокировки (optimistic locking): если кто-то другой изменил продукт первым, Doctrine выбросит OptimisticLockException, и второй запрос может просто повторить попытку.

Сохранение продукта с помощью репозитория

Наш Product готов. Но где мы его сохраняем? Домен не может использовать Doctrine. Поэтому здесь мы определяем только  интерфейс:

namespace App\Catalog\Domain\Model\Product;
 
interface ProductRepository
{
    public function nextIdentity(): ProductId;
    public function add(Product $product): void;
    public function productOfId(ProductId $id): ?Product;
    public function existsWithTitle(string $title): bool;
}

Почему это лучше:

  •  Один репозиторий на агрегат: только ProductRepository. Никакого ReviewRepository, потому что отзывы живут внутри продукта.
  •  Никакого  flush(): репозиторий работает как коллекция. Транзакция обрабатывается уровнем выше.
  •  Легко тестировать: вы можете написать InMemoryProductRepository буквально в несколько строк.

Кто его реализует? Не Домен. Мы вернемся к этому в слое Инфраструктуры.

Уникальность названий с помощью доменного сервиса (Domain Service)

Посмотрите на плакат еще раз. Одно правило отличается от остальных: «Никаких одинаковых названий!»

Все правила до этого момента укладывались внутри одного Product. Продукт может проверить свою цену, длину своего названия и свой статус. Но может ли продукт знать названия  всех остальных продуктов? Нет. Он знает только себя.

Когда бизнес-правило не относится к конкретной сущности или value-объекту, мы используем  Доменный сервис (Domain Service):

namespace App\Catalog\Domain\Model\Product;
 
final readonly class UniqueProductTitle
{
    public function __construct(private ProductRepository $productRepository) {}
 
    public function check(string $title): void
    {
        if ($this->productRepository->existsWithTitle($title)) {
            throw new ProductTitleAlreadyUsed("A product named \"$title\" already exists.");
        }
    }
}

Вот и всё! Это по-прежнему чистый код Домена. Он общается с интерфейсом ProductRepository, а не с Doctrine. И его имя взято из слов эксперта, а не из нашей головы. Никаких ProductDomainService или ProductHelper.

 Примечание: Вы можете подумать: «Отлично! Тогда давайте засунем все правила в доменные сервисы!» Пожалуйста, не надо. Всегда сначала спрашивайте себя: может ли сущность сделать это сама? В большинстве случаев ответ — да. Слишком большое количество доменных сервисов снова вернет вас к анемичному Product с раздутыми сервисами. Та же старая проблема, только под новым именем.

Наш Домен готов. Посмотрите на каждый класс: никаких use Symfony…, никаких use Doctrine\ORM…. Только чистый PHP и его правила.

Но подождите. Кто создает Product? Кто вызывает publish()? Где мы все это вызываем?

Application (Приложение)

Слой приложения содержит наши  сценарии использования (use cases): «создать продукт», «опубликовать продукт», «добавить отзыв». Он принимает входные данные из внешнего мира и говорит Домену, что делать. Здесь все еще нет кода фреймворка.

Передача входных данных с помощью команды (Command)

 Команда (Command) — это простой объект данных (DTO), который несет входные данные для одного сценария использования. Ему все равно, откуда пришли эти данные: из API, формы или консоли.

namespace App\Catalog\Application\Service\Product;
 
final readonly class CreateProductCommand
{
    public function __construct(
        public string $title,
        public string $description,
        public int $priceAmount,
        public string $currency,
        public string $image,
    ) {}
}

Три простых правила:

  •  Только примитивные типы. Сервис сам соберет Value-объекты.
  •  Никакой логики, никакой валидации. Валидацию делает Домен.
  •  Никаких атрибутов  #[Assert]. Команды остаются свободными от фреймворка.

 Примечание: Вы можете спросить: «Команда для записи, а для чтения?» Хороший вопрос! Для чтения мы используем  Запрос (Query), например ProductQuery, и он возвращает DTO ProductResponse. Раздельные модели для записи и чтения — это  CQRS в его простейшем виде. Наш CreateProductService возвращает только ID нового продукта. Это небольшой практический компромисс: клиенту нужно знать, какой продукт был создан.

Создание продукта с помощью сервиса приложения (Application Service)

 Сервис приложения (Application Service) запускает один сценарий использования от начала до конца. Вот наш:

namespace App\Catalog\Application\Service\Product;
 
final readonly class CreateProductService
{
    public function __construct(
        private ProductRepository $productRepository,
        private UniqueProductTitle $uniqueProductTitle,
        private TransactionalSession $session,
    ) {}
 
    public function execute(CreateProductCommand $command): string
    {
        return $this->session->executeAtomically(function () use ($command) {
            $this->uniqueProductTitle->check($command->title);
 
            $product = new Product(
                id: $this->productRepository->nextIdentity(),
                title: $command->title,
                description: $command->description,
                price: new ProductPrice($command->priceAmount, Currency::fromCode($command->currency)),
                image: $command->image,
            );
 
            $this->productRepository->add($product);
 
            return (string) $product->id;
        });
    }
}

Посмотрите, какой он короткий. Проверить название, создать продукт, сохранить его, вернуть ID. Все реальные правила остаются в Домене.

TransactionalSession — это небольшой интерфейс в нашем слое приложения:

namespace App\Shared\Application;
 
interface TransactionalSession
{
    public function executeAtomically(callable $operation): mixed;
}

Помните, наш репозиторий никогда не вызывает flush(). Сессия оборачивает весь сценарий использования в одну транзакцию. Все или ничего.

Другие сценарии использования выглядят аналогично. Вот важная часть AddProductReviewService:

// ❌ The service checks a business rule
if ($product->status !== ProductStatus::Published) {
    throw new \DomainException('Only a published product can be reviewed.');
}
 
// ✅ The service only asks the Product
$product->addReview($command->customerId, new Rating($command->rating), $command->comment);

Если вы видите бизнес-условие if в сервисе приложения, это знак. Это правило должно переехать в Домен.

 Примечание: Вы можете спросить: «Сервис приложения или доменный сервис? Они оба сервисы!» Хороший вопрос! Вот разница:

  •  Сервис приложения (Application Service): запускает один сценарий использования. Загружает данные, вызывает Домен, сохраняет. Знает о транзакции. Не содержит бизнес-правил. Пример: CreateProductService.
  •  Доменный сервис (Domain Service): содержит бизнес-правило, которое не помещается в одну сущность. Знает только о Домене. Пример: UniqueProductTitle.
  •  Инфраструктурный сервис (Infrastructure Service): выполняет техническую работу, например, отправку email или общение с Doctrine. Пример: DoctrineProductRepository, к которому мы скоро перейдем.

Золотые правила:

  1. Конструктор принимает только классы и интерфейсы Домена и Приложения.
  2. Один сервис, одна задача, один публичный метод: execute().
  3. execute() принимает только Command или Query.
  4. execute() возвращает Response DTO, простой ID или ничего. Никогда не возвращает сущность.
  5. Если контроллеру нужно более двух сервисов, создайте один агрегирующий сервис, который вызывает их. (Это правило из моей личной практики.)

PublishProductService и AddProductReviewService находятся в репозитории.

Наш сценарий использования готов. Но кто вызывает execute()? Где мы вызываем этот сервис?

Посмотрим на зависимости

Прежде чем ответить, давайте остановимся на секунду и посмотрим на то, что мы построили:

src/Catalog/
├── Domain/          Product, ProductPrice, Rating, ProductRepository, UniqueProductTitle
├── Application/     CreateProductCommand, CreateProductService
└── Infrastructure/  (empty, for now)
  •  Домен (Domain) не имеет зависимостей. Он не знает о существовании Приложения.
  •  Приложение (Application) использует Домен.
  •  Инфраструктура (Infrastructure) будет использовать Приложение.

Три слоя, один над другим. Это выглядит как классическая  Многослойная архитектура (Layered Architecture).

Но обратите внимание на одну маленькую деталь. ProductRepository — это  интерфейс, и он живет в Домене. TransactionalSession — тоже интерфейс, и он живет в Приложении. Их реальные реализации будут жить снаружи, в Инфраструктуре. Держите это в уме. Это ключ к следующему разделу.

Infrastructure (Инфраструктура)

Инфраструктура — это место, где наконец появляются Symfony и Doctrine. У нее две задачи:  сохранять наши данные и  доставлять наши сценарии использования во внешний мир.

Сохранение продукта с помощью адаптера Doctrine

 Шаг 1. Реализуем репозиторий

namespace App\Catalog\Infrastructure\Persistence\Doctrine;
 
final readonly class DoctrineProductRepository implements ProductRepository
{
    public function __construct(private EntityManagerInterface $entityManager) {}
 
    public function nextIdentity(): ProductId
    {
        return new ProductId(Uuid::v7()->toRfc4122());
    }
 
    public function add(Product $product): void
    {
        $this->entityManager->persist($product); // no flush here
    }
 
    public function productOfId(ProductId $id): ?Product
    {
        return $this->entityManager->find(Product::class, $id);
    }
 
    public function existsWithTitle(string $title): bool
    {
        return $this->entityManager->getRepository(Product::class)->count(['title' => $title]) > 0;
    }
}

 Шаг 2. Реализуем транзакцию

namespace App\Shared\Infrastructure\Persistence;
 
final readonly class DoctrineTransactionalSession implements TransactionalSession
{
    public function __construct(private EntityManagerInterface $entityManager) {}
 
    public function executeAtomically(callable $operation): mixed
    {
        // begins, flushes and commits; rolls back on error
        return $this->entityManager->wrapInTransaction(fn () => $operation());
    }
}

 Шаг 3. Указываем Symfony, какой адаптер использовать

Помните #[AsAlias] из моей статьи про Service Container? Он здесь идеально подходит:

#[AsAlias(ProductRepository::class)]
final readonly class DoctrineProductRepository implements ProductRepository
 
#[AsAlias(TransactionalSession::class)]
final readonly class DoctrineTransactionalSession implements TransactionalSession

Вот и всё! Никакой конфигурации не требуется. Наш CreateProductService запрашивает ProductRepository, и Symfony отдает ему DoctrineProductRepository.

Теперь посмотрите на направление стрелок. В классической многослойной архитектуре бизнес-слой вызывает слой базы данных. Здесь все наоборот.  Doctrine зависит на нашего Домена, потому что она реализует наш интерфейс. Наш Домен ничего не знает о Doctrine.

Это  Гексагональная архитектура (Hexagonal Architecture), также называемая  Порты и Адаптеры (Ports and Adapters):

  •  Порт (port) — это интерфейс, который принадлежит нашему ядру: ProductRepository, TransactionalSession.
  •  Адаптер (adapter) — это реализация, которая живет снаружи: DoctrineProductRepository, DoctrineTransactionalSession.

Наше ядро находится посередине. Doctrine — это просто один из адаптеров, подключенный к одному из портов. Завтра вы можете подключить другой, и Домен не изменит ни единой строчки кода.

 Примечание: Вы можете подумать: «У нас был интерфейс с самого начала. Значит, архитектура изначально была гексагональной?» Отличный вопрос! Да, так и было. Мы просто не давали ей названия, пока не увидели обе стороны.

Маппинг продукта без изменения Домена

Наш Product не имеет атрибутов Doctrine. Как же Doctrine узнает о таблице? Мы храним маппинг в Инфраструктуре.

 Шаг 1. Регистрируем путь к маппингу

# config/packages/doctrine.yaml
doctrine:
    orm:
        mappings:
            Catalog:
                type: php
                dir: '%kernel.project_dir%/src/Catalog/Infrastructure/Persistence/Doctrine/Mapping'
                prefix: 'App\Catalog\Domain\Model'

Мы говорим Doctrine: «Классы находятся в Домене, но их маппинг лежит в Инфраструктуре». Мы используем  PHP-драйвер маппинга.

 Шаг 2. Пишем маппинг

// Infrastructure/Persistence/Doctrine/Mapping/App.Catalog.Domain.Model.Product.Product.php
 
return static function (ClassMetadata $metadata): void {
    $builder = new ClassMetadataBuilder($metadata);
    $builder->setTable('catalog_products');
 
    $builder->createField('id', 'product_id')->makePrimaryKey()->build();
    $builder->createField('title', 'string')->length(100)->unique()->build();
    $builder->addEmbedded('price', ProductPrice::class, 'price_');
    $metadata->mapField(['fieldName' => 'status', 'type' => 'string', 'enumType' => ProductStatus::class]);
    $builder->createField('version', 'integer')->isVersionField()->build();
 
    $builder->createOneToMany('reviews', Review::class)
        ->mappedBy('product')
        ->cascadePersist()
        ->orphanRemoval()
        ->build();
};

Несколько моментов, на которые стоит обратить внимание:

  •  Имя файла имеет значение! Оно должно состоять из полного имени класса с точками. Если вы назовете его Product.php, Doctrine его не найдет.
  •  Файл возвращает замыкание (closure). В старых туториалах запись велась в глобальную переменную $metadata. Этот стиль устарел начиная с версии doctrine/persistence 4.2.
  • addEmbedded() сохраняет наш ProductPrice в две колонки: price_amount и price_currency. ProductPrice получает свой собственный крошечный файл маппинга как embeddable. См. репозиторий.
  • isVersionField() включает оптимистическую блокировку, о которой мы говорили ранее.
  • product_id — это небольшой кастомный тип Doctrine, который преобразует наш ProductId в строку и обратно. Один атрибут регистрирует его: #[AsDbalType('product_id')] на классе типа. См. репозиторий.

 Примечание: Вы можете подумать: «Зачем нужен unique() для названия? У нас же уже есть UniqueProductTitle!» Хороший вопрос! Два запроса могут проверить одно и то же название в одну и ту же секунду, и оба пройдут проверку. Domain Service дает четкое бизнес-сообщение. Индекс базы данных — это наша страховочная сетка.

Итак, откуда мы вызываем CreateProductService? Из любой точки входа во внешний мир: API-контроллер, консольная команда или обработчик Messenger лишь преобразуют свои входные данные в CreateProductCommand и вызывают execute(), в то время как Domain и Application слои остаются абсолютно неизменными.

Наш продукт сохранен. Но команда Inventory (Склад) пока еще не знает об этом.

Назад к Domain: Domain Events

Сообщаем системе о том, что произошло, с помощью Domain Events

Когда продукт публикуется, контексту  Inventory необходимо создать для него складскую единицу. Но наш Product не должен знать об Inventory. Поэтому Product просто объявляет: «Я был опубликован». Все, кому это интересно, могут подписаться на это событие.

Это и есть  Domain Event (доменное событие): что-то важное, что произошло в бизнесе, названное в прошедшем времени.

 Шаг 1. Создаем события

namespace App\Shared\Domain\Event;
 
abstract readonly class CommonEvent
{
    public \DateTimeImmutable $occurredOn;
 
    public function __construct(?\DateTimeImmutable $occurredOn = null)
    {
        $this->occurredOn = $occurredOn ?? new \DateTimeImmutable();
    }
}namespace App\Catalog\Domain\Model\Product;
 
final readonly class ProductPublished extends CommonEvent
{
    public function __construct(
        public string $productId,
        public string $title,
        public int $priceAmount,
        public string $currency,
        ?\DateTimeImmutable $occurredOn = null,
    ) {
        parent::__construct($occurredOn);
    }
}

 Шаг 2. Создаем издателя (publisher)

DomainEventPublisher — это простой синглтон. Любой, кто хочет слушать события, реализует интерфейс DomainEventSubscriber:

interface DomainEventSubscriber
{
    public function handle(CommonEvent $event): void;
    public function isSubscribedTo(CommonEvent $event): bool;
}
 
final class DomainEventPublisher
{
    // instance(), subscribe(), unsubscribe() ... see the repo
 
    public function publish(CommonEvent $event): void
    {
        foreach ($this->subscribers as $subscriber) {
            if ($subscriber->isSubscribedTo($event)) {
                $subscriber->handle($event);
            }
        }
    }
}

 Шаг 3. Публикуем событие из Product

public function publish(): void
{
    // ... check the rule, change the status
 
    DomainEventPublisher::instance()->publish(new ProductPublished(
        productId: (string) $this->id,
        title: $this->title,
        priceAmount: $this->price->amount,
        currency: $this->price->currency->value,
    ));
}

Помните, почему мы генерировали id самостоятельно? Вот именно поэтому. Событию нужен id продукта  до того, как продукт будет сохранен. Метод addReview() публикует событие ProductReviewed точно так же.

Золотые правила:

  1. Не публикуйте события из слоя Infrastructure (например, из контроллера).
  2. События должны быть сериализуемыми: в конструкторе должны быть только примитивные значения.
  3. Используйте readonly классы для событий.
  4. Используйте именованные аргументы при создании события.
  5. Публикуйте события внутри Domain Model.

Теперь наш Product сообщает миру о том, что произошло. Давайте убедимся, что мир может это услышать.

Взаимодействие между Bounded Contexts

Сообщаем Inventory о опубликованных продуктах с помощью Messenger

zU9KvpCnMxRj3Mzt1onswjVzeLjYB4Agac1f9YyG.webp

Каждый ограниченный контекст (bounded context) — это остров со своей собственной моделью. Они лишь отправляют друг другу сообщения, как бумажные кораблики.

Все наши контексты живут в одном приложении Symfony, в разных папках: src/Catalog, src/Inventory и так далее. Но они никогда не изменяют данные друг друга напрямую. Они только отправляют сообщения. Symfony Messenger идеально подходит для этого.

Помните заметку: «Опубликован? Сообщи Inventory, но только после сохранения». Inventory должен узнать о продукте  только после того, как он действительно будет сохранен. Если транзакция завершится ошибкой, никакое сообщение не должно быть отправлено.

 Шаг 1. Собираем события во время выполнения юзкейса

namespace App\Shared\Infrastructure\Messaging;
 
final class DomainEventCollector implements DomainEventSubscriber
{
    private array $events = [];
 
    public function isSubscribedTo(CommonEvent $event): bool
    {
        return true;
    }
 
    public function handle(CommonEvent $event): void
    {
        $this->events[] = $event;
    }
 
    public function release(): array
    {
        [$events, $this->events] = [$this->events, []];
 
        return $events;
    }
}

 Шаг 2. Отправляем их после коммита

Обновим наш DoctrineTransactionalSession:

public function executeAtomically(callable $operation): mixed
{
    $collector = new DomainEventCollector();
    $subscriberId = DomainEventPublisher::instance()->subscribe($collector);
 
    try {
        $result = $this->entityManager->wrapInTransaction(fn () => $operation());
    } finally {
        DomainEventPublisher::instance()->unsubscribe($subscriberId);
    }
 
    foreach ($collector->release() as $event) {
        $this->messageBus->dispatch($event); // only after a successful commit
    }
 
    return $result;
}

Если транзакция завершается ошибкой, wrapInTransaction() выбрасывает исключение, и мы никогда не доходим до цикла foreach. Никаких сообщений для продукта, который так и не был сохранен.

 Шаг 3. Маршрутизируем события в Redis

# config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed
        transports:
            async: '%env(MESSENGER_TRANSPORT_DSN)%' # redis://localhost:6379/messages
            failed: 'doctrine://default?queue_name=failed'
        routing:
            App\Shared\Domain\Event\CommonEvent: async

 Шаг 4. Обрабатываем событие в Inventory

namespace App\Inventory\Infrastructure\Messaging;
 
#[AsMessageHandler]
final readonly class WhenProductPublishedCreateStockItem
{
    public function __construct(private CreateStockItemService $service) {}
 
    public function __invoke(ProductPublished $event): void
    {
        $this->service->execute(new CreateStockItemCommand(productId: $event->productId));
    }
}php bin/console messenger:consume async

Вот и все! Продукт публикуется в Catalog, а на складе в Inventory появляется соответствующая позиция. Ни один из контекстов не знает, как устроен другой. Inventory знает только о событии.

Почему это лучше:

  •  Безопасно: никаких сообщений для продукта, который не был сохранен.
  •  Независимо: если Inventory «лежит», Catalog все равно работает. Сообщение подождет в Redis, и Inventory обработает его позже.
  •  Легко масштабировать: контекст Orders (Заказы) может слушать то же самое событие со своим собственным обработчиком.

 Примечание: Вы можете подумать: «А что если Redis упадет сразу после коммита?» Хороший вопрос! В таком случае сообщение будет утеряно. Для продакшена используйте  паттерн Outbox: сохраняйте события в таблицу вашей собственной базы данных в той же транзакции, и пусть воркер пересылает их в Redis. Но это тема для отдельной статьи.

Наш путь завершен.

Заключение

Мы начали со страницы блокнота, заполненной бизнес-правилами от нашего Catalog Manager. Теперь каждое правило живет в коде, сформулированное его же словами:

  •  $product->publish() заменил setStatus(), разбросанный по разным сервисам.
  •  ProductPrice заменил int $price и повторяющиеся проверки.
  •  addReview() заменил скопированный код расчета среднего рейтинга.
  •  UniqueProductTitle заменил проверку уникальности, которая дублировалась в каждой форме и скрипте импорта.
  •  Application Services предоставили каждому юзкейсу единый дом — как для API, так и для консоли.
  •  Порты и адаптеры заменили Doctrine и Symfony внутри нашего бизнес-кода.
  •  Domain Events и Messenger заменили прямые вызовы между контекстами.

И помните обещание, данное в начале? Давайте докажем его одной командой:

grep -rE 'use (Symfony|Doctrine\\ORM)' src/Catalog/Domain
# (no output)

Ноль импортов фреймворка. В блокноте менеджера ни разу не упоминается Symfony, как и в нашем Domain. (Наш Product использует Doctrine\Common\Collections для отзывов. Но это небольшая независимая библиотека, а не сама ORM.)

ИИ-ассистент может написать все эти классы за вас за считанные минуты. Но он не сможет поговорить с вашим Catalog Manager, определить ограниченные контексты или решить, где именно должно находиться то или иное правило. Эта часть работы по-прежнему остается за нами. И DDD дает нам карту для этого пути.

Бизнес живет годами. Фреймворки приходят и уходят. Если завтра Symfony исчезнет, наш Product этого даже не заметит.