Комментарии 55
1. У вас довольно странная реализация VO для идентификаторов.
$depositId = DepositId::fromString('550e8400-e29b-41d4-a716-446655440000');
$wishId = WishId::fromString('550e8400-e29b-41d4-a716-446655440000');

var_dump($depositId->equalTo($wishId)); //???

Кроме того статический метод next тоже не очень. Лучше вынести такое в сервис-генератор, который можно будет мокнуть в тестах или застабать в функциональных тестах на АПИ (Вы будете знать какой идентификатор будет сгенерирован для сущности).

2. createdAt можно тестировать и без аргумента, указав дельту в 1 секунду к примеру, или использовав Carbon

3. Вы только что жестко привязали свою доменную модель к доктрине
$this->deposits = new ArrayCollection();

Тоже касается ваших манипуляций в методе getDepositById. Теперь без доктрины ваша БЛ не работает.

4. Агрегат не должен отдавать наружу сущности. getDeposits должен возвращать массив идентификаторов, но не сами депозиты (Но это уже мое ИМХО)
Вы только что жестко привязали свою доменную модель к доктрине

Вполне допустима привязка домена к библиотекам общего назначения. Вы же не замечаете привязок к стандартной библиотеки, к Uuid, к Money и т. п. Это всё "чистые" библиотеки, не производящих значимых сайд-эффектов.


Агрегат не должен отдавать наружу сущности.

Корень агрегата должен обеспечивать контроль за жизненным циклом сущностей, входящих в него, отдавать он их может, главное, чтобы ссылки на них постоянно негде больше не хранились и состояние не менялось без контроля агрегата. К сожалению, в PHP нет технических способов предоставить доступ к методам только некоторым классам вне иерархии наследования — нет ни дружественных модификаторов, ни области модуля/неймспэйса. Соображения практичности часто заставляют не городить параллельную сущностям иерархию DTO/массивов, кучу прокси-геттеров и/или вовсю использовать Reflection, когда можно обойтись соглашениями не хранить ссылки на части агрегата и не изменить их состояния кроме как через методы корня.


getDeposits должен возвращать массив идентификаторов, но не сами депозиты

Если депозиты части агргеата, то не должно быть как раз способа по идентификатору депозита получить сущность депозита (или какое-то её представление, если не следовать правилам практичности выше) кроме как обратившись к корню агргегата. В таком случае нет смысла возвращать идентификаторы этих сущностей из метода агрегата, если подавлющее большинство сценариев использования интересуются исключительно целыми сущностями, а не идентификаторами, поскольку всё равно они обратятся к тому же корню агрегата, чтобы после getDeposits получить все сущности по идентификаторам.

К Uuid как раз нет привязки, ибо он завернут в собственные VO.
Вот Money провтыкал. На рабочем проекте мы тоже эту либу юзаем, но она точно также завернута в свой VO и в домене нигде не светится.

Чуть ниже я потом писал, что по моему мнению, желание как раз не является агрегатом, так что этому методу совсем не место в классе Wish

Какая разница во что завернут? Такие VO — неотъемлемая часть домена. По сути мы делаем классы типа Collection, Uuid, Money и т. п. частью домена так же как DateTime. То, что некоторые из них часть стандартной библиотеки, часть реализована в нестандартных расширениях, а часть — обычные PHP-классы — техническая деталь. То же и с функциями типа strlen, count и т. п. стандартной библиотеки — если можем использовать их, то можем использовать и сторонние библиотеки. С другой стороны, не можем использовать ни функции стандартных библиотеки типа работы с ФС, сетью или БД, ни подобные функции сторонних. То есть допустимость использования в домене зависит от того, что функция/класс делает, а не от того, откуда мы их подключаем.

Насчет пункта 3, я долго извращался, выпиливал этот ArrayCollection на уровне репозитория (фактически, нужен кастомный гидратор, который будет вместо ArrayCollection создавать примитивный array).
В конце концов решил, что лучше пусть уж в конструкторе останется ArrayCollection, но во всех методах, которые с ним работают, переменная используется как будто она массив. Как меньшее зло, работает норм.
  1. Запилил хотфикс! Тут действительно баг.
  2. Надо подумать.
  3. Я привязал бизнес-логику только к Doctrine\Common, т.к. ArrayCollection именно оттуда.
  4. А как в таком случае мне получить все вклады в желание?
3. Да, но все-же. Я бы предпочел отказаться от этой зависимости, но не отрицаю что я могу быть не прав.
4. Через findByWish у репо депозитов.
Через findByWish у репо депозитов.

Депозит тут является частью желания, не имеющим смысла вне его, частью агрегата, корнем которого является желание. Отдельный репо для депозита тут не просто не нужен, а запрещен определением агрегата — доступ к депозиту можно получить только через желание.

Название статьи не соответствует действительности. Тут скорее лишь попытка применения теоретических знаний о DDD на простейшем сферическом примере в вакууме, делеком от реальных требований бизнеса.
Ожидал, наконец, увидеть применение на реальном проекте с историей о том как введенные на начальном этапе адовы слои абстракции DDD помогали (или же мешали) развитию проекта в будущем.
Тут скорее лишь попытка применения теоретических знаний о DDD на простейшем сферическом примере в вакууме, делеком от реальных требований бизнеса.

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

Но их почему-то нет.

  1. Очень большой объём материала, минимум на книгу
  2. NDA
5. Зачем вы возвращаете строку, а не сам VO WishName?
6. Написал и подумал. Wish не является агрегатом для Deposit. Это просто 2 сущности. Потому метод deposit не должен находится там, ИМХО.
Wish не является агрегатом для Deposit. Это просто 2 сущности. Потому метод deposit не должен находится там, ИМХО.

Почему вы так считаете?

Боюсь что я не смогу этого объяснить. Потому и добавил в конце ИМХО. Возможно кто-то более опытный приведет аргументы за или против по этому вопросу
у вас даже начало статьи принципиально не DDD-шное, и показывает, что вы мыслите CRUD подходом.
Потому, что статью вы начинаете с того, как создаете проект и базу данных для него.
Для Domain Drive Design — это абсолютно не важно. Детали реализации, которые должны быть вынесены за рамки статьи. Так как один из главных принципов DDD, который отражается в проектировании это persistence ignorance.

Дальше вы выбираете набор атрибутов, которые нужно хранить в классе. Не рассмотрев домен и как действуют сущности в нем.
Отталкиваясь от того какие ключи в Базу повесить.
Это не DDD, это CRUD c его подходом — Forms over Data.
Процесс проектирования в DDD начинается с чего-то вроде Event Stroming, где основная идея это выделить основные доменные события (Domain Events) и то как посредством их взаимодействуют доменные сущности, а также определиться с именованием этих сущностей (Ubiquitous Language)
Ну и судя по статье вы сделали классический God object из своего объекта Wish. В нем зашили вообще всю логику домена, нарушив при этом Single Responsibility принцип множество раз.
Само количество публичных методов у этого объекта (а их там 20) толжно было дать вам почувствовать «smell» в вашем коде.

А каким образом эту логику можно было бы разбить на части?

Это вот одна из причин почему я считаю, что желание не агрегат и стоит разделить процесс создания и модификации желаний от процесса с балансом (депозит \ виздрав)

Скорее стоит ввести специализированную, а не общую коллекцию для депозитов, в которой и хранить общую коллекцию и имплементировать все операции затрагивающие исключительно депозиты.

Непонятно абслютно почему Deposit — это сущность, а не объект-значение? Инициализируется при создании и потом не изменяется.

У вас тут целая куча проблем.


  1. Уберите раздел Предыстория из статьи.
    Вы в начале конфигурируете проект под Symfony, Docker, VueJs и прочее, но в статье они ни как не фигурируют больше.
    Ваща статья исключительно про DDD. VueJs только пару раз упомянулся, но на практике не использовался.
    Возможно вы будете их использовать в следующих статьях.
    Вот тогда и напишете про Docker и VueJs.


  2. Сходу проблемы с DDD.


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

    Вы Фонд потеряли в своем проекте. Все финансовые транзакции делаются через Фонд накопления средств, а не через сущность Желание.
    Я бы их вообще разделил на 2 отдельных контекста (Bounded Context).


  3. Как уже сказали, AbstractId::next() лучше вынести в сервис генерации id.


    interface WishIdGenerator
    {
       public function next(): WishId;
    }

  4. Я бы не привязывался так явно к UUID.
    Вдруг захотите сменить генератор id.
    Я сейчас готовлю статью по использованию более оптимального id чем UUID.


  5. Статические фабричные методы AbstractId::fromString() и Expense::fromCurrencyAndScalars вам вообще не нужны.
    Вы все должны деалть через конструктор и передавать в него явные значения, а не генерировать VO внутри.


  6. Использование getter-ов и setter-ов это известный DDD антипаттерн.
    Лучше переименовать методы:


    • AbstractId::getId() в AbstractId::id()
    • WishName::getValue() в WishName::name()
    • Expense::getCurrency() в Expense::сurrency()
    • Wish::getFund() в Wish::fund()
      и т.д.
      Кто-то может со мной не согласится, но по мне так префикс get тут лишний.

  7. publish/unpublish


    например, вы можете отложить его до лучших времен

    Вы явно описали действие: отложить
    Тоесть действия у вас будут:


    • отложить до лучших времен — postpone
    • возобновить накопление — resume

    В некоторых местах вы говорите:


    Публиковать и убирать в черновики

    Что немного противоречит. Черновики это отдельная история и тоже делается не через unpublish.


  8. Вангуем дату исполнения.
    Логика расчитана на то, что вы вклад делаете каждый день, но этого нет в условии.
    В нашей стране распространеное двух этапная выплата зарплаты — аванс и зарплата.
    Соответсвенно, делать вклады в таком случае чаще 2 раз в месяц затруднительно.
    Вообще это все сильно зависит от возможостей делать вклады.
    Я бы закладывал ежемесечные вклады, но это сильно зависит от бизнеса.


  9. С вычетами и удалениями депозитов у вас тоже не все впорядке.


    Ну, или, например, если вы откладывали на желание достаточно большие суммы, а потом просто «промахнулись», не уследив за количеством уже имеющихся средств.

    Вклад это фиксированная величина. Вы не можете удалить вклад после внесения его в фонд, так как он растворяется в нем.
    В фонде у вас хранится общая сумма и история вкладов (если она вам нужна).
    Внесение вклада это внесение средств. Вы делаете вклад в копилку и вносите в нее деньги и теперь денег в копилке стало больше.
    Вклада в ней нет. В ней только деньги. По сути вклад обертка над Money.
    Если вы по ошибке сделаи вклад не на то желание, то вы можете сделать транзакцию по переводу средств из одного фонда в другой на размер последнего вклада или любую другую величину.
    Если вы внесли в фонд больше денег чем хотели, то вы можете изьять сумму из конкретного фонда.
    Вы не обязаны извлекать из фонда сумму равную какому-то вкладу.
    Например, вы внесли 50 рублей, а потом поняли что 24 рубля 74 копейки из них были лишними (я утрирую но мысль я думаю вы поняли) и хотите извлеч из вклада конкретную сумму денег.
    Да и вообще, вам по жизни может потребоваться извлечь произвольную суммму из вклада.


  10. Вы уверены что накопленные средства = исполнению желания?


    По мере накопления достаточного количества средств желание становится исполненным.

    Я бы не ставил между ними равно.
    Исполнение желания это одно, а накопление достаточной суммы для исполнения желания это савсем другое.
    И не ограничивайте явно потолок накопления суммы. Вспоминаем Kickstarter.


  11. Вклад не должен знать о Желании.
    Вы сделали рекурсивную ссылку, а это плохо.
    Правильней так:
    • Есть Желание и Фонд накопления средств на конкретное Желание.
    • Если Фонд выносить в отдельный контекст, то лучше делать связь от Фонда к Желанию и тогда:
    • У Желания есть цена.
    • Желание не знает о Фонде.
    • Фонд знает о Желании и как следствие о его цену.
    • Фонд накапливает сумму на исполнение Желания.
    • Вклад не знает ничего ни о Фонде, ни о Желании. Это просто деньги.
    • Мы сами определяем в какой Фонд внести Вклад.
    • Вклад это VO.
    • После внесения Вклада в Фонд он превращается (если это нам нужно) в Транзакцию в Истории транзакций фонда.
    • Транзакции нельзя удалять или изменять. Это уже история.

Там еще целая гора мелких недочетов с реализацией финансовой части. Я не буду тут вдаваться в подробности.

Большое спасибо за развернутый комментарий! Вы знаете, по поводу Транзакций вместо вкладов я уже думал в процессе написания статьи, т.к. посмотрел еще раз на код. Действительно, при необходимости мы можем любую сумму из одного желания «переложить» в другое. Тогда это уже не Вклад, а Транзакция. Так как относительно одного желания это будет «минус», относительно другого — «плюс». Не стал об писать, т.к. не было полной уверенности относительно этого, но теперь вы меня убедили. В принципе, можно отрефакторить это дело.

Советую переосмыслить и хорошенечко подумать над всей схемой. И не пару часов, а лучше несколько дней/недель.
Тогда вы возможно сможете лучше понять вашу предметную область и написать статью — работу над ошибками. В ней можно больше сконцентрировать на описании и формулировании предметной области, а не на конкретной реализации. В статью можно будет добавить схемы взаимодействия, схемы транзакций и все прочее. Вот это точно будет взрывня статью.


DDD это не про реализацию, а про проектирование. Качественно продуманная и сформулированная предметная область легко реализуется на любом языке программирования. А вот проработать эту самую предметную область и есть основная проблема.


И не подумайте что я вас критикую. Ваше стремление похвально.


PS: Рекомендую к прочтению книгу Вон Вернона — Implementing Domain-Driven Design.

Всё же DDD предполагает, по-моему, многоитерационный (условно бесконечный) цикл "уточнение знаний"->"проектирование"->"реализация"->"уточнение знаний", причём этапы вовсе не обязаны чётко разделяться в "водопадно-гостовском" стиле как внутри цила, так и по итерациям.


Я слабо представляю себе успешную реализацию серьезного проекта по DDD без постоянного переделывания сделанного по получению фидбэка от экспертов, пробующих очередной билд системы.


Я к тому, что выражение "работа над ошибками" в отношении именно моделирования домена плохо подходит, лучше что-то вроде "переработка модели в связи с получением новой информации".

Согласен. Пожалуй да. Работа с DDD это бесконечный процесс.
Мой предыдущий комментарий был как раз о том что былоб интересно почитать про итерации переработки.
Некоторые попытки описания итераций были у Вернона, но они не полные.
Многие описывают итерации переработки, но редко больше двух.


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

Небольшое пояснение на счёт транзакций.
Если вы изучаете DDD, то, я думаю, вы это знать, но я все же поясню. Вдруг кто не вкурсе.


В примере который описал я под ваши задачи, Транзакция существует только в рамках Истории транзакций. Перевод средств из Фонда одного Желания в Фонд другого это бизнес транзакция. Не объект транзакция. В результате перевода средств будут созданы 2 объекта Транзакции в Истории транзакций одного и второго Фондов. Тоесть История транзакций напоминает Event Sourcing. В коде, перевод средств может иметь вид:


$fund1->trasfer($money, $fund2);

Всё остальное делается внутри.
Вклад тоже является Транзакцией только в Истории транзакций.
Если у нас существует сущность Кошелёк или Счёт, то мы можем делать вклад используя их. Что-то тип:


$fund->invest($money, $account);

Здесь кстати не очень понятно. Возможно лучше делать вклад через кошелек:


$account->invest($money, $fund);

Если Вклады появляются из неоткуда, как у вас, то лучше делать VO на мой взгляд.


$fund->invest($deposit);

Но это все чисто рассуждения на тему.

В результате перевода средств будут созданы 2 объекта Транзакции в Истории транзакций одного и второго Фондов.

Сильно зависит от выбранной модели учёта транзакций. Транзакция может иметь поля типа "фонд-источник" и "фонд-получатель" и тогда перевод требует только одной транзакции. А внесение в фонд или вывод из него могут маркироваться какими-то специальными случаями, в простейшем варианте — null.

Делал я похожий сайт для личных нужд, с одним лишь отличием, что это были не «хотелки», а в полне реальное планирование, у каждой цели были сроки (с, по) и сумма: ДР родственников, оплата интернета (плачу раз в год), продление доменов, серверов, покупка машины, взносы в собственный пенсионный фонд и т.д. Вообщем все что выше 1000 руб попадало в эту систему. Система сама рассчитывала, какие цели активны, какие просрочены, сколько нужно вносить в день/неделю/месяц (оч. хорошо мотивирует по жизни ;) и т.д. Изначально реализовал так что у каждой цели был отдельный банк/фонд (как хотите) и с каждого взноса деньги распределялись на все цели в зависимости от текущего отставания по ней (накоплено меньше, чем должно к моменту взноса). Потом почти год пользовался ей — в целом сносно, но разделение обезличенных денег между целями было ошибкой. Достаточно иметь общую кубышку с историей пополнения/списания, а цели нужно рассматривать как отдельный bounding context, так как они лишь вас стимулируют, не более. В случае нехватки денег за пределами системы вы всеравно залезете в кубышку, главное чтобы в истории была зафиксирована дата и сумма списания.

P.S. Если ваша система считает сколько нужно вложить в месяц/неделю/день — рекомендую, эти показатели только увеличивать автоматически, а не уменьшать (только в ручную). Хитрость: раз вы жили без этих денег какое-то время и выжили, значит они вам не так уж и нужны — лучше им будет в кубышке, быстрее цели закроете ;)

Добрый день.


  1. Вы используете генератор ID сущности, а затем передаете этот ID в конструктор. Чем этот подход лучше или хуже того, если создавать идентификатор внутри конструктора, на пример при помощи использования той же ramsey/uuid: $this->id = Uuid::uuid1()?
  2. Использование библиотеки «webmozart/assert» — в документации указано "*All assertions in the Assert class throw an \InvalidArgumentException if they fail*." Это означает, что мы уже не можем работать с собственным «деревом» исключений в домене. На сколько это допустимо? Я в своих приложениях стараюсь использовать исключения от наследованные от собственного корня, для дальнейшего удобства работы с ними. Я думаю, что для разработки библиотек использование системных исключений более чем оправдано. Но при разработке приложения — это доставляет некоторые проблемы. Кто что думает по этому поводу?
  1. Это с одной стороны инкапсуляция логики создания идентификатора, с другой — возможность удобно совершать по нему выборки.
  2. Ну, я использовал её для тех случаев где, как мне кажется, можно «кинуть» стандартный InvalidArgumentException. Иначе бы там пришлось создавать уйму классов исключений на каждый чих.
  1. Про инкапсуляцию я понял, а вот про «другую» сторону не совсем
  2. Различные типы исключений бросаются исходя из типа ошибки, а не из контекста. По этому «кастомных» типов исключений будет не так уж и много и большая часть из них будет совпадать по именованию с системными (прим. InvalidArgumentException). Я хотел у знать опыт коллег по цеху — кто придерживается данного подхода?
  1. Абстрагируемся от конкретного вида идентификатора и способа его создания, что позволяет его легко менять. Скажем, если будем использовать последовательности СУБД для идентификаторов, то получать их в конструкторе сущности может быть весьма проблематично.


  2. Как по мне, то webmozart/assert и подобные библиотеки должны использоваться только для простейших проверок аргументов конструкторов, сеттеров и т. п., реализуя что-то вроде строгой типизации, но не проверки ограничений бизнес-логики и для этого InvalidArgumentException достаточно. Если уж очень хочется, то можно делать что-то вроде
    try {
    Assert::empty($arg);
    } catch (InvalidArgumentException $e) {
    throw new SomeDomainException($e);
    }
Вопрос от новичка
Что означают два вопросительных знака в выражении
$this->createdAt = $createdAt ?? new DateTimeImmutable();

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

Этот оператор позволяет взять значение справа, если значение слева — null.

Для null достаточно ?: Главное, что ?? позволяет избежать ошибок, если первый операнд вообще не определён, причём на негорначинуую вложененность, то есть конструкция типа $options['default']['action'] ?? null вернёт null независимо от того не определен сам $options или какой-то из интересующих ключей.

Точно. Уже кажется настолько очевидным, что забыл об этом)

А почему вы решили именно использовать `assert`-ы, а не выбрасывать собственные исключения, например?

Валидация входных данных (например, из HTTP-запроса) и обеспечение корректности создаваемого объекта — разные вещи. Поэтому объект-значение вполне вправе самостоятельно проверять данные, которые в него поступают.

Возможно вы правы. Не претендую на истину. Но тогда наш объект приобретает две роли, валидация и хранение значения.

И есть еще вопрос, не связанный напрямую с темой. Судя по пространству имен и PSR-4 вы храните Domain object и Value object в одной директории. Насколько это оправдано?

PSR тут значения не имеет. Рядом хранятся вещи, связанные по смыслу.

Я к тому что DO и VO это же разные по смыслу вещи, разве нет?

Они неразрывно связаны часто и потому лучше хранить их вместе, если можно сказать, что значение принадлежит сущности, только она его контролирует, создаёт и уничтожает, а остальные части системы только используют это значение только для чтения, по необходимости "изменить" обращаясь к сущности

В таком случае исключения не будет, если вы это хотели сказать своим комментарием.

Если хранение данных выносить в отдельную роль, то полноценных объектов не останется: в одних будут данные храниться, а в других обрабатываться. Одни будут не объектами, а структурами, а другие не объектами, а наборами процедур. Полноценный объект совмещает данные и методы для их обработки, его ответственность в том какие данные и какие методы. Само хранение и обработка не ответственность, а общее свойство всех объектов, лишь иногда вырождающееся в "никакие данные" или "никакой обработки", часто лишь для удобства и единообразия обёрнутое в объект. Или потому что язык по другому не позволяет.


Ответственность ValueObject с таким подходом не "хранить и валидировать строку name", а "удобно и безопасно представлять в системе допустимое значение имени" или, более формально, "представлять в системе значение имени, соблюдая все условия и инварианты бизнес и технических требований к нему".


Кроме того, валидация для меня немного другой процесс. Assert или исключение в конструкторе или мутаторе доменных объектов (сущностей, значений, сервисов) — это для обеспечения целостности модели, как foreign key в РСУБД в теории, а на практике чуть ли не последняя линия обороны от данных, приводящих систему в недопустимое состояние, именно поэтому чаще всего исключения технические бросаются, которые только в редких случаях приводятся к сообщению пользователю, что он что-то сделал не так. Ошибки домена — это 500, а не 400.


В случае компилируемых языков с мощной системой типов ввместо assert в рантайме просто аргумент был бы NonEmptyString или типа того и код вообще бы не скомпилировался.


Именно валидация пользовательского ввода (данных от внешних источников в общем случае) должна проходить на UI/Infrastructure слоях и быть, как минимум, не менее строгой, чем в домене, а начинаться вообще на фронте, а не на бэке в случае современного софта. Да, многократное дублирование одних и тех же проверок на разных уровня от html-атрибута required до ограничений на уровне базы типа not null или более мощных, если у нас, например, свежий постгри, а не старый мускуль.


Я предпочитаю и настаиваю на код-ревью, чтобы как минимум Entities (ваш Domain Object?), ValueObjects и интерфейс репозитория одного агрегата лежали в одном неймспейсе/папке (с разбиением внутри на подпапки, если их больше 9). Исключение разве что ValueObject, которые используются по всей системе и базовые абстрактные классы/интерфейсы. Вообще негативно отношусь к разбиению на базе "паттернов": "сюда мы складываем сущности, сюда репозитории, сюда контроллеры, да ещё добавляем префиксы/суффиксы" — малейшее сквозное изменение и затрагивается иной раз больше десятка папок.

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