Comments 56
Вы только что жестко привязали свою доменную модель к доктрине
Вполне допустима привязка домена к библиотекам общего назначения. Вы же не замечаете привязок к стандартной библиотеки, к Uuid, к Money и т. п. Это всё "чистые" библиотеки, не производящих значимых сайд-эффектов.
Агрегат не должен отдавать наружу сущности.
Корень агрегата должен обеспечивать контроль за жизненным циклом сущностей, входящих в него, отдавать он их может, главное, чтобы ссылки на них постоянно негде больше не хранились и состояние не менялось без контроля агрегата. К сожалению, в PHP нет технических способов предоставить доступ к методам только некоторым классам вне иерархии наследования — нет ни дружественных модификаторов, ни области модуля/неймспэйса. Соображения практичности часто заставляют не городить параллельную сущностям иерархию DTO/массивов, кучу прокси-геттеров и/или вовсю использовать Reflection, когда можно обойтись соглашениями не хранить ссылки на части агрегата и не изменить их состояния кроме как через методы корня.
getDeposits должен возвращать массив идентификаторов, но не сами депозиты
Если депозиты части агргеата, то не должно быть как раз способа по идентификатору депозита получить сущность депозита (или какое-то её представление, если не следовать правилам практичности выше) кроме как обратившись к корню агргегата. В таком случае нет смысла возвращать идентификаторы этих сущностей из метода агрегата, если подавлющее большинство сценариев использования интересуются исключительно целыми сущностями, а не идентификаторами, поскольку всё равно они обратятся к тому же корню агрегата, чтобы после getDeposits получить все сущности по идентификаторам.
Какая разница во что завернут? Такие VO — неотъемлемая часть домена. По сути мы делаем классы типа Collection, Uuid, Money и т. п. частью домена так же как DateTime. То, что некоторые из них часть стандартной библиотеки, часть реализована в нестандартных расширениях, а часть — обычные PHP-классы — техническая деталь. То же и с функциями типа strlen, count и т. п. стандартной библиотеки — если можем использовать их, то можем использовать и сторонние библиотеки. С другой стороны, не можем использовать ни функции стандартных библиотеки типа работы с ФС, сетью или БД, ни подобные функции сторонних. То есть допустимость использования в домене зависит от того, что функция/класс делает, а не от того, откуда мы их подключаем.
В конце концов решил, что лучше пусть уж в конструкторе останется ArrayCollection, но во всех методах, которые с ним работают, переменная используется как будто она массив. Как меньшее зло, работает норм.
- Запилил хотфикс! Тут действительно баг.
- Надо подумать.
- Я привязал бизнес-логику только к
Doctrine\Common
, т.к.ArrayCollection
именно оттуда. - А как в таком случае мне получить все вклады в желание?
Ожидал, наконец, увидеть применение на реальном проекте с историей о том как введенные на начальном этапе адовы слои абстракции DDD помогали (или же мешали) развитию проекта в будущем.
Тут скорее лишь попытка применения теоретических знаний о DDD на простейшем сферическом примере в вакууме, делеком от реальных требований бизнеса.
По большому счёту, так и есть. И я это обозначил в самом начале статьи. И по поводу реальных проектов тоже соглашусь, мне бы тоже хотелось видеть подобные статьи. Но их почему-то нет.
Потому, что статью вы начинаете с того, как создаете проект и базу данных для него.
Для Domain Drive Design — это абсолютно не важно. Детали реализации, которые должны быть вынесены за рамки статьи. Так как один из главных принципов DDD, который отражается в проектировании это persistence ignorance.
Дальше вы выбираете набор атрибутов, которые нужно хранить в классе. Не рассмотрев домен и как действуют сущности в нем.
Отталкиваясь от того какие ключи в Базу повесить.
Это не DDD, это CRUD c его подходом — Forms over Data.
Процесс проектирования в DDD начинается с чего-то вроде Event Stroming, где основная идея это выделить основные доменные события (Domain Events) и то как посредством их взаимодействуют доменные сущности, а также определиться с именованием этих сущностей (Ubiquitous Language)
Само количество публичных методов у этого объекта (а их там 20) толжно было дать вам почувствовать «smell» в вашем коде.
Непонятно абслютно почему Deposit — это сущность, а не объект-значение? Инициализируется при создании и потом не изменяется.
У вас тут целая куча проблем.
Уберите раздел Предыстория из статьи.
Вы в начале конфигурируете проект под Symfony, Docker, VueJs и прочее, но в статье они ни как не фигурируют больше.
Ваща статья исключительно про DDD. VueJs только пару раз упомянулся, но на практике не использовался.
Возможно вы будете их использовать в следующих статьях.
Вот тогда и напишете про Docker и VueJs.
Сходу проблемы с DDD.
у каждого желания есть стоимость, начальный фонд и накопленные средства — фонд
Вы Фонд потеряли в своем проекте. Все финансовые транзакции делаются через Фонд накопления средств, а не через сущность Желание.
Я бы их вообще разделил на 2 отдельных контекста (Bounded Context).
Как уже сказали,
AbstractId::next()
лучше вынести в сервис генерации id.
interface WishIdGenerator { public function next(): WishId; }
Я бы не привязывался так явно к UUID.
Вдруг захотите сменить генератор id.
Я сейчас готовлю статью по использованию более оптимального id чем UUID.
Статические фабричные методы
AbstractId::fromString()
иExpense::fromCurrencyAndScalars
вам вообще не нужны.
Вы все должны деалть через конструктор и передавать в него явные значения, а не генерировать VO внутри.
Использование getter-ов и setter-ов это известный DDD антипаттерн.
Лучше переименовать методы:
AbstractId::getId()
вAbstractId::id()
WishName::getValue()
вWishName::name()
Expense::getCurrency()
вExpense::сurrency()
Wish::getFund()
вWish::fund()
и т.д.
Кто-то может со мной не согласится, но по мне так префикс get тут лишний.
publish/unpublish
например, вы можете отложить его до лучших времен
Вы явно описали действие: отложить
Тоесть действия у вас будут:
- отложить до лучших времен — postpone
- возобновить накопление — resume
В некоторых местах вы говорите:
Публиковать и убирать в черновики
Что немного противоречит. Черновики это отдельная история и тоже делается не через
unpublish
.
Вангуем дату исполнения.
Логика расчитана на то, что вы вклад делаете каждый день, но этого нет в условии.
В нашей стране распространеное двух этапная выплата зарплаты — аванс и зарплата.
Соответсвенно, делать вклады в таком случае чаще 2 раз в месяц затруднительно.
Вообще это все сильно зависит от возможостей делать вклады.
Я бы закладывал ежемесечные вклады, но это сильно зависит от бизнеса.
С вычетами и удалениями депозитов у вас тоже не все впорядке.
Ну, или, например, если вы откладывали на желание достаточно большие суммы, а потом просто «промахнулись», не уследив за количеством уже имеющихся средств.
Вклад это фиксированная величина. Вы не можете удалить вклад после внесения его в фонд, так как он растворяется в нем.
В фонде у вас хранится общая сумма и история вкладов (если она вам нужна).
Внесение вклада это внесение средств. Вы делаете вклад в копилку и вносите в нее деньги и теперь денег в копилке стало больше.
Вклада в ней нет. В ней только деньги. По сути вклад обертка надMoney
.
Если вы по ошибке сделаи вклад не на то желание, то вы можете сделать транзакцию по переводу средств из одного фонда в другой на размер последнего вклада или любую другую величину.
Если вы внесли в фонд больше денег чем хотели, то вы можете изьять сумму из конкретного фонда.
Вы не обязаны извлекать из фонда сумму равную какому-то вкладу.
Например, вы внесли 50 рублей, а потом поняли что 24 рубля 74 копейки из них были лишними (я утрирую но мысль я думаю вы поняли) и хотите извлеч из вклада конкретную сумму денег.
Да и вообще, вам по жизни может потребоваться извлечь произвольную суммму из вклада.
Вы уверены что накопленные средства = исполнению желания?
По мере накопления достаточного количества средств желание становится исполненным.
Я бы не ставил между ними равно.
Исполнение желания это одно, а накопление достаточной суммы для исполнения желания это савсем другое.
И не ограничивайте явно потолок накопления суммы. Вспоминаем Kickstarter.
- Вклад не должен знать о Желании.
Вы сделали рекурсивную ссылку, а это плохо.
Правильней так:
- Есть Желание и Фонд накопления средств на конкретное Желание.
- Если Фонд выносить в отдельный контекст, то лучше делать связь от Фонда к Желанию и тогда:
- У Желания есть цена.
- Желание не знает о Фонде.
- Фонд знает о Желании и как следствие о его цену.
- Фонд накапливает сумму на исполнение Желания.
- Вклад не знает ничего ни о Фонде, ни о Желании. Это просто деньги.
- Мы сами определяем в какой Фонд внести Вклад.
- Вклад это 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.
Добрый день.
- Вы используете генератор ID сущности, а затем передаете этот ID в конструктор. Чем этот подход лучше или хуже того, если создавать идентификатор внутри конструктора, на пример при помощи использования той же ramsey/uuid: $this->id = Uuid::uuid1()?
- Использование библиотеки «webmozart/assert» — в документации указано "*All assertions in the Assert class throw an \InvalidArgumentException if they fail*." Это означает, что мы уже не можем работать с собственным «деревом» исключений в домене. На сколько это допустимо? Я в своих приложениях стараюсь использовать исключения от наследованные от собственного корня, для дальнейшего удобства работы с ними. Я думаю, что для разработки библиотек использование системных исключений более чем оправдано. Но при разработке приложения — это доставляет некоторые проблемы. Кто что думает по этому поводу?
- Это с одной стороны инкапсуляция логики создания идентификатора, с другой — возможность удобно совершать по нему выборки.
- Ну, я использовал её для тех случаев где, как мне кажется, можно «кинуть» стандартный
InvalidArgumentException
. Иначе бы там пришлось создавать уйму классов исключений на каждый чих.
- Про инкапсуляцию я понял, а вот про «другую» сторону не совсем
- Различные типы исключений бросаются исходя из типа ошибки, а не из контекста. По этому «кастомных» типов исключений будет не так уж и много и большая часть из них будет совпадать по именованию с системными (прим. InvalidArgumentException). Я хотел у знать опыт коллег по цеху — кто придерживается данного подхода?
Абстрагируемся от конкретного вида идентификатора и способа его создания, что позволяет его легко менять. Скажем, если будем использовать последовательности СУБД для идентификаторов, то получать их в конструкторе сущности может быть весьма проблематично.
- Как по мне, то 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 или какой-то из интересующих ключей.
Валидация входных данных (например, из 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, которые используются по всей системе и базовые абстрактные классы/интерфейсы. Вообще негативно отношусь к разбиению на базе "паттернов": "сюда мы складываем сущности, сюда репозитории, сюда контроллеры, да ещё добавляем префиксы/суффиксы" — малейшее сквозное изменение и затрагивается иной раз больше десятка папок.
Что я ожидал от статьи: концепции типа единого языка (подобие глоссария все-таки было) / ограниченные контексты / аггрегаты.
Что я получил? Валидацию в обьектах похожих на DTO, генерацию UUID снаружи и TDD. А, ну еще 25 мин потраченного времени
DDD на практике. Проектирование списка желаний