Комментарии 11
Иными словами — вы придумали еще один фреймворк.
Скрытый текст

Попробуем года через 2, когда перейдем с 8.2 на 8.4
Разделение на слои выглядит достаточно странно. Из приведенного в статье кода логично разделить на слои таким образом Controller, Application, Domain, Repository.
Спасибо за наблюдение. Понимаю, почему предложенная структура может показаться странной — она действительно отличается от классической четырёхслойки Controller → Application → Domain → Repository.
В статье используется другой принцип разделения: не по техническому типу, а по зависимостям (Dependency Inversion Principle).
Controller и Repository — это оба инфраструктурные адаптеры. Controller адаптирует HTTP к Application-слою, Repository адаптирует Application-слой к БД. Оба зависят от доменных интерфейсов, но домен о них ничего не знает. Поэтому они живут рядом в src/ (Infrastructure) — это внешний слой, который «подключается» к ядру.
Если вынести Repository в отдельный слой между Domain и Infrastructure, возникает вопрос: интерфейс репозитория или реализация? Интерфейс — это часть домена (он описывает контракт персистентности на языке домена), он уже живёт в core/Product/Domain/. А реализация — это инфраструктурная деталь (конкретный SQL, конкретная БД), поэтому она в src/Repository/.
Кроме того, в DDD с bounded contexts важно первичное разделение по контексту, а не по техническому слою. Структура Product/Domain/, Product/Application/, Order/Domain/, Order/Application/ позволяет каждому контексту быть автономным. Deptrac контролирует, что Order/Domain не зависит от Product/Domain — это было бы сложнее гарантировать при группировке «все репозитории вместе, все контроллеры вместе».
Но если проект без bounded contexts и без CQRS — ваша четырёхслойка Controller → Application → Domain → Repository вполне рабочий вариант. Для простых приложений она даже нагляднее.
Doctrine и DDD — это двойной маппинг
А точно ли нужен двойной маппинг? Doctrine поддерживает ValueObject и кастомные типы.
Ваша Doctrine сущность может иметь вид:
#[ORM\Entity]
#[ORM\Table(name: 'products')]
class Product
{
#[ORM\Id]
#[ORM\Column(type: 'ProductId')]
private readonly ProductId $id;
#[ORM\Column(type: 'ProductName')]
private ProductName $name;
#[ORM\Column(type: 'Money')]
private Money $price;
// ... геттере и сеттере 🤔 Это же не DTO
}
Если хотите воспользоваться объявлением свойств в конструкторе, то придется перенести маппинг в Yaml.
Основной мнус доктриновской сущности, в контексте DDD, это то, что сущность Doctrine нельзя объявить как final. Doctrine использует прокси-классы через наследование для сущностей. Чисто технические ограничения.
Это все натягивание совы на глобус. Т.е. попытка замапить сущности ДДД в доктрину - это сплошные костыли, потому что изначально доктрина как инструмент для этого вообще никак не предназначена. Доктрина сама по себе инструмент низкого уровня. Она влияет на структуру БД, вообще схема доктрины = схема в БД. А если смотреть на ДДД, то в разных ограниченных контекстах разные сущности могут смотреть в одни и те же таблицы, или наборы таблиц. Т.е. прямой связи со структурой БД нет. Поэтому нельзя утверждать что можно как-то замапить сущности доктрины в сущности ДДД через ямл. Сущность ДДД может в итоге собираться из 2х или 3х таблиц, а сущность доктрины - это одна таблица. Пример может быть такой: два ограниченных контекста, в одном сущность менеджер, собирается из таблиц юзер + таблицы для менеджера с его спецификой. В другом контексте сущность кастомер собирается так-же из таблицы юзер + таблицы для кастомера. В таком случае в доктрине будут описаны сущности именно для доктрины - которые будут описывать юзера и разные специфики для менеджера и для кастомера, и в ДДД они потом будут мапится в разных комбинациях.
Это все натягивание совы на глобус.
Не был бы так категоричен)
А если смотреть на ДДД, то в разных ограниченных контекстах разные сущности могут смотреть в одни и те же таблицы
Это тоже решается. Ни кто же не говорит, что сущность доктрины смотрящая на одну таблицу должна быть единственной на весь проекте. Можно создавать доктриновские сущности как вьюхи для таблицы с необходимым набором полей и режимом работы readonly.
в одном сущность менеджер, собирается из таблиц юзер + таблицы для менеджера с его спецификой
В некоторых случаях можно решить вопрос используя связь OneToOne.
Основная проблема доктриновских сущностей, в том, что они позволяют описать не более 3 уровней вложенности (Entity -> Embedded -> Custom Type) без джойнов других сущностей, а в DDD вложенность агрегата может превышать 3 уровня.
Возможно в DoctrineODM нет этих ограничений. Не изучал этот вопрос.
Альтернативный вариант.
Вместо ручного маппинга доктриновские сущностей на DDD агрегаты, можно в репозитории писать нативные запросы со всеми джойнами какие нужны, а результат маппить уже на DDD агрегат средствами самой доктрины (да, так тоже можно).
И теперь вы пишете код для маппинга ProductEntity -> Product и обратно. Двойная работа. Два набора классов. Два набора тестов. И постоянный вопрос: «А стоит ли DDD таких накладных расходов?»
Основной мой посыл в том, что в таком виде это стрельба из пушки по воробьям. Если не используется функционал Doctrine, тогда зачем вообще Doctrine. Либо используйте её нормально, либо не используйте вообще.
Doctrine это не просто маппилка таблички из БД на класс в php.
Ну статья как раз об этом вот всём. Не понял к чему эти комментарии.
Мои комментарии к тому, что сравнительная таблица должна выглядеть так:

Спасибо за развёрнутый комментарий — видно, что вы глубоко работали с Doctrine в контексте DDD, и ваши замечания по делу. Попробую ответить по пунктам.
Про «либо используйте нормально, либо не используйте вообще» — собственно, статья именно об этом. Мы выбрали «не использовать вообще». Вы правы, что Doctrine — это не просто маппилка. Это полноценный ORM с Identity Map, Unit of Work, Change Tracking. Но в контексте DDD с CQRS эти механизмы часто не помогают, а мешают. Unit of Work конфликтует с явным save() в репозитории. Identity Map создаёт неочевидное состояние между bounded contexts. Proxy-объекты не дают сделать сущность final — вы сами об этом упомянули.
Про custom types и Embedded — да, через кастомные типы можно протащить Value Objects в Doctrine-сущность. Но вы же сами отметили ограничение в 3 уровня вложенности (Entity → Embedded → Custom Type). В реальном DDD агрегат может быть глубже. И тут начинаются обходные пути — OneToOne, нативные запросы с ручным маппингом через Doctrine. Каждый из них рабочий, но каждый — это компромисс, где вы боретесь с инструментом, а не используете его по назначению.
Про нативные запросы в репозитории — вы предлагаете «писать нативные запросы со всеми джойнами и маппить результат на DDD-агрегат средствами самой Doctrine». Но если вы пишете нативный SQL, сами маппите результат, не используете Identity Map, не используете Unit of Work — что именно вам даёт Doctrine? По сути, вы используете DBAL-обёртку над PDO. Rowcast делает ровно то же самое, только без 8 МБ зависимостей и без абстракций, которые в этом сценарии не работают.
Про сравнительную таблицу — согласен, что её можно дополнить третьим столбцом: «Doctrine, используемая правильно в DDD-контексте» (custom types + native queries + ручная гидрация). Но тогда этот столбец будет выглядеть подозрительно похоже на Rowcast — прямой SQL, ручной маппинг, без ORM-магии — только с бОльшим размером vendor/ и неиспользуемыми абстракциями под капотом.
Суть статьи не в том, что Doctrine плохой инструмент. Doctrine — отличный инструмент для своих задач. Суть в том, что для легковесного DDD-микросервиса с CQRS можно обойтись значительно более простым стеком, где каждый компонент делает ровно одну вещь и не тянет за собой механизмы, которые приходится обходить.

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