Бизнес живет годами. Фреймворки приходят и уходят.
Фреймворки приходят и уходят. Помните 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-проект начинается с разговора, а не с кода. Перед тем как написать первую строчку, мы пообщались с контент-менеджером нашего магазина. Он не пишет код, но знает каждое правило каталога. И он записал их для нас:

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, к которому мы скоро перейдем.
Золотые правила:
- Конструктор принимает только классы и интерфейсы Домена и Приложения.
- Один сервис, одна задача, один публичный метод: execute().
- execute() принимает только Command или Query.
- execute() возвращает Response DTO, простой ID или ничего. Никогда не возвращает сущность.
- Если контроллеру нужно более двух сервисов, создайте один агрегирующий сервис, который вызывает их. (Это правило из моей личной практики.)
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 точно так же.
Золотые правила:
- Не публикуйте события из слоя Infrastructure (например, из контроллера).
- События должны быть сериализуемыми: в конструкторе должны быть только примитивные значения.
- Используйте readonly классы для событий.
- Используйте именованные аргументы при создании события.
- Публикуйте события внутри Domain Model.
Теперь наш Product сообщает миру о том, что произошло. Давайте убедимся, что мир может это услышать.
Взаимодействие между Bounded Contexts
Сообщаем Inventory о опубликованных продуктах с помощью Messenger

Каждый ограниченный контекст (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 этого даже не заметит.
Комментарии (0)
Пока нет комментариев — будьте первым.