Спасибо за статью, много информации к размышлению. У меня вопрос: а почему если возникла необходимость изменения состояния двух агрегатов из одного ограниченного контекста в рамках одной транзакции, мы не можем сделать это изменение в одной команде? Да, это будет нарушением рекомендации DDD, но какие практические проблемы у нас могут при этом возникнуть?
Но в достаточно большой системе (достаточной для того, чтобы проектировать её согласно DDD) достаточно часто так не будет получаться (достаточно для того, чтобы изначально делать такую архитектуру, где бизнес-логика реализуется вне доменных объектов)
Возможно я вас не правильно понял, но с моей токи зрения логика типа, "если сумма кредита превышает 1000 долларов и кредитная нагрузка клиента превышает 80% то заявка на кредит должна быть отклонена", это не что иное как бизнес логика, и она должна быть реализована внутри доменного объекта Кредитная Заявка.
Вы уверены? Если для без подтверждения email-а пользователь ничего делать не может, то он вроде как и не зарегистрировался. И наверняка, чуть позже другой пользователь попробует зарегистрироваться с этим email-ом, подтвердить его и только тогда его регистрация завершится. Кстати, на многих сайтах всё именно так и работает. А порой список действий, который должны быть выполнены, прежде пользователь станет таки зарегистрированным, ещё больше
Вариантов решения может быть несколько. Например мы можем на первом этапе создавать не объект Пользователя, а объект Регистрация, с ограниченным сроком действия. А пользователя создавать только когда эта Регистрация опубликует событие что она успешна. Или другой вариант: мы можем удалять пользователей с не подтвержденным е-мейлом через какое то время. Не вижу причины почему такой подход не может использоваться для бизнес процесса с большим количеством шагов.
Таким образом, вопрос в том, где у вас проходит граница бизнес-логики. Если в конкретном случае она помещается в рамках одной транзакции работы с объектом - то ок, может пробовать запихать внутрь. Но в достаточно большой системе (достаточной для того, чтобы проектировать её согласно DDD) достаточно часто так не будет получаться (достаточно для того, чтобы изначально делать такую архитектуру, где бизнес-логика реализуется вне доменных объектов)
Любой многоэтапный бизнес процесс состоит из серии команд, каждая из которых изменяет состояние бизнес объекта или группы объектов. Алгоритм изменения состояния этого объекта согласно ООП должен быть реализован внутри этого объекта. Если бизнес процесс затрагивает изменение состояния нескольких объектов, либо несколько последовательных изменений состояния одного объекта то часть логики может находится вне этих объектов, но эта логика не манипулирует состоянием этих объектов напрямую и не делает никаких проверок которые касаются состояния этих объектов. Программный модуль который реализует эту логику слушает доменные события, которые эти объекты публикуют и принимает решение какие команды на изменение состояния этим объектам выслать. Реализация такого программного модуля тоже может содержать какое-то состояние, как минимум чтобы понимать на каком этапе находится бизнес процесс (паттерн орекстрация). Но может никакого состояния и не иметь (паттерн хореография). Именно так выглядит архитектура больших систем со сложной бизнес логикой.
Конструктор в нашем приложении может много где вызываться. И даже при вычитывании пользователя из БД. Потому конструктор класса - точно не подходит.
Зависит от реализации, есть фреймворки которые позволяют полностью абстрагироваться от хранилища и вызывать конструктор только один раз, как будто объект после создания и добавления в репозиторий все время находится в оперативной памяти а не в БД.
А если будет требование подтверждения того, что email принадлежит пользователю? То после записи в БД (с проверкой уникальности) надо будет ему ещё письмо послать и подождать, пока он ссылку из письма откроет, а это кто сделает? А если ещё надо проверить, нет ли этого email или комбинации фио+возраст+город в списках фрода?
Тогда пользователь создаётся с неподтвержденным емейлом, публикуется событие User Created на которое реагирует сабскрайбер который шлёт на этот е-мейл письмо со ссылкой подтверждения. По клику на ссылке выполняется метод User::confirmEmail который помечает е-мейл пользователя как подтвержденный. И публикует событие Email Confirmed
Другой подписчик может на это событие инициировать проверку в списке фрода. Ендпойнт который получает результат проверки, если она выполняется асинхронно, передает его в метод User::completeRegistration который а зависимости от этого результата публикует либо событие Registration Completed либо Registration Failed.
Как видите вся доменная логика, которая касается регистрации пользователя, находится внутри объекта User несмотря на то что для принятия некоторых решений требуются данные из внешних источников.
И если уж вернуться к теме топика, то можно понять, требование уникальности email - это внешнее требование, а потому не должно быть внутри нашего объекта User.
Не обязательно, я бы сформулировал это требования так. "Нельзя создать пользователя, если его email уже используется другим пользователем". И самым логичным и надежным местом для проверки этого бизнес требования с моей точки зрения является конструктор объекта Пользователь. При таком подходе у нас нет никакой физической возможности в коде создать невалидного пользователя в системе. И такую проверку легко можно покрыть юнит тестом объекта Пользователь.
Да никто не мешает всё делать в рамках ООП (лично я именно так и делаю).
Я не говорю, что объект заказ должен быть анемичным.
И в тоже время:
Объекты доменной модели вообще ничего не знали про логику, а сервисы бизнес-логики не вылезали за границы своей ответственности.
Я тут вижу явное противоречие. По моему тут описана как раз анемичная модель.
Но с точки зрения классов в ООП, кто-то является заказом, а кто-то должен олицетворять элементы бизнес-процесса, а кто-то оркестрировать всем этим ансамблем.
Оркестрация нужна когда в рамках одной большой бизнес транзакции меняется состояние нескольких доменных объектов. Есть специальные шаблоны предназначенные для этого, такие как сага или оркестратор, но ни тот ни другой не отражает актора, они отражают бизнес-процесс на уровне "если бизнес-объект А опубликовал событие B запусти метод С бизнес объекта D".
Я вообще не вижу для себя особого смысла в репрезентации акторов в виде программных модулей, кроме как в контексте проверки прав доступа к системе. Какая мне разница кто инициировал изменение состояния системы, реальный человек менеджер использующий GUI, или другая автоматическая система через API? Моя задача определить какая бизнес сущность отвечает за это состояние и передать ей соответствующую команду на изменение. По какому алгоритму это изменение происходит и какие проверки в ходе этого изменения нужно произвести знает только эта сущность, а не какой-то сервис который является внешним по отношению к этой сущности, и не должен знать ничего о ее внутренний реализации, в том числе и о том в каких атрибутах эта сущность хранит свое состояние. Для этого сервиса сущность это черный ящик с набором команд на изменение состояния. Да и по сути это даже не сервис доменного слоя, это сервис слоя приложения, который по определению не должен содержать никакой бизнес логики.
Естественно что приведенные фрагменты кода не моделирует весь бизнес процесс, а лишь один из его этапов. Каждый из этих этапов заключается в изменении состояния объекта Заказ по определенному алгоритму который включает в себя ряд проверок, является ли это состояние валидным. Мой вопрос состоит в том что мешает вам реализовать этот алгоритм внутри объекта, как это постулирует ООП и в частности богатая модель, а не снаружи, в сервисе который манипулирует состоянием этого объекта?
Ок, кажеться я вас понял и даже могу с вами в некоторой степени согласится. Действительно доменный сервис в виде набора процедур сгруппированных определенным образом наверное можно интерпретировать как субъекта, который взаимодействует с системой. Не буду спорить, если вам это облегчает понимание кода. Но я по прежнему не понимаю, что вам мешает при таком подходе использовать нормальное ООП с богатой доменной моделью. В чем принципиальная разница меду этими двумя фрагментами кода с точки зрения моделирования бизнес процесса:
<?php
final class DeliveryManager
{
public function deliverOrder(string $orderId):void {
$order = $this->orderRepository->get($orderId);
if($order->status !== 'paid') {
throw new RuntimeException('Order is not paid');
}
//еще какая-то логика по доставке...
$order->status = 'delivered';
}
}
<?php
final class DeliveryManager
{
public function deliverOrder(string $orderId):void {
$order = $this->orderRepository->get($orderId);
$order->deliver(); //вся логика по доставке находится внутри объекта
}
}
Именно процедурный, ваши сервисы это ни что иное как наборы процедур которые оперируют структурами данных (анемичными моделями). Объектов в понимании ООП, у которых есть и состояние и поведение одновременно тут нет.
Т.е получается чтобы реализовать сценарий доставки заказа нам нужно два модуля: сервис курьера и сервис получателя заказа? И сервис курьера должен передать заказ сервису получателя заказа, ведь именно так все происходит а реальном мире, не так ли? Есть два актора один из которых передает товар а второй получает.
Программный код всего лишь моделирует реальный мир а не воспроизводит его в точности. При этом используются различные подходы. То что вы описали это процедурный подход и он имеет ряд недостатков по сравнению с объектным. Точно также можно сказать, что в реальном мире при доставке заказа менеджер не устанавливает значение доставлен в атрибут статус заказа и не сохраняет его в репозиторий, на самом деле это курьер передает заказ в руки клиента.
Да порог входа в DDD высокий, и разделить домен на ограниченные контексты с первого раза бывает непросто, но при правильном применении такой код гораздо проще поддерживать, чем клубок из сервисов, которые меняют состояние доменной модели кто во что горазд.
Не уверен что репозиторий это хорошее место для помещения бизнес логики. Обычно интерфейс репозитория объявляется в слое домена или приложения, а реализация а слое инфраструктуры. Бизнес-логика не должна находиться в слое инфраструктуры. Да и ответственность репозитория должна быть ограничена сохранением и получением данных из хранилища, проверка бизнес-правил это ответственность слоя домена.
Если нам важна целостность на уровне "каждой заявке в нашей системе должна соответствовать попытка создания заявки в банке", то мы не можем слать запрос в банк перед тем как наша заявка сохранится в базе, если не важна, то можем и тогда outside может иметь сайд эффект.
Хотя наверное вы правы, возможно в данном случае мы можем сделать заявку изнутри агрегата, конечно существует вероятность что после создания заявки в банке состояние агрегата может не сохранится, но в данном случае это может быть не критично, и упростит нам реализацию механизма резервации заявки. Т.е. outside может позволять изменение состояния внешних сервисов в ситуации если нам не критична целостность этого состояния по отношению к агрегату.
Классический пример из книги IDDD - метод репозитория nextIdentity(), некоторые реализации которого вполне себе может вносить изменения в БД, и при этом он может вызываться из любой фабрики, некоторые из которых вполне могут быть и методами агрегата.
Метод nextIdentity() не изменяет состояния внешних сервисов, и роллбек транзакции в рамках которой осуществляется сохранение агрегата не приведет к нарушению целостности состояния ограниченного контекста.
Но помимо него в жизни немало ситуаций, когда для принятия решения бизнес-логике необходимо сначала попытаться что-то изменить во внешней системе.
Тот же курс обмена на бирже обычно зависит от текущего состояния стакана, т.е. от доступного объёма ликвидности предлагаемого с разными уровнями цены. И если нет механизма резервирования, то нет и гарантий, что обмен удастся выполнить (как по определённому курсу, так и в принципе что на него хватит ликвидности).
Можно, конечно, устроить целую сагу с доменными событиями, eventual consistency, несколькими шагами и компенсацией если следующий шаг провалился
Пример который я привел придуманный и сильно упрощен в целях демонстрации принципа outside а не работы банковской системы. Если перед принятием бизнес решения нужно изменить состояние внешней системы, то это изменение нужно производить за пределами агрегата, как реакцию на доменное событие. Грубо говоря, если мы можем узнать курс только как результат создания заявки в банке, то мы вначале создаем заявку в своем контексте без курса, только с информацией о банке в котором нужно сделать заявку, а запрос на создание заявки в банке шлем уже в подписчике на событие BidCreated, и в случае успеха записываем этот курс в методе Bid::confirm(float $echangeRate, DateTime $expiresAt); если же по какой-то причине заявку в банке создать не удалось то выполняем метод Bid::cancel()
Спасибо за статью, много информации к размышлению. У меня вопрос: а почему если возникла необходимость изменения состояния двух агрегатов из одного ограниченного контекста в рамках одной транзакции, мы не можем сделать это изменение в одной команде? Да, это будет нарушением рекомендации DDD, но какие практические проблемы у нас могут при этом возникнуть?
Возможно я вас не правильно понял, но с моей токи зрения логика типа, "если сумма кредита превышает 1000 долларов и кредитная нагрузка клиента превышает 80% то заявка на кредит должна быть отклонена", это не что иное как бизнес логика, и она должна быть реализована внутри доменного объекта Кредитная Заявка.
Вариантов решения может быть несколько. Например мы можем на первом этапе создавать не объект Пользователя, а объект Регистрация, с ограниченным сроком действия. А пользователя создавать только когда эта Регистрация опубликует событие что она успешна. Или другой вариант: мы можем удалять пользователей с не подтвержденным е-мейлом через какое то время. Не вижу причины почему такой подход не может использоваться для бизнес процесса с большим количеством шагов.
Любой многоэтапный бизнес процесс состоит из серии команд, каждая из которых изменяет состояние бизнес объекта или группы объектов. Алгоритм изменения состояния этого объекта согласно ООП должен быть реализован внутри этого объекта. Если бизнес процесс затрагивает изменение состояния нескольких объектов, либо несколько последовательных изменений состояния одного объекта то часть логики может находится вне этих объектов, но эта логика не манипулирует состоянием этих объектов напрямую и не делает никаких проверок которые касаются состояния этих объектов. Программный модуль который реализует эту логику слушает доменные события, которые эти объекты публикуют и принимает решение какие команды на изменение состояния этим объектам выслать. Реализация такого программного модуля тоже может содержать какое-то состояние, как минимум чтобы понимать на каком этапе находится бизнес процесс (паттерн орекстрация). Но может никакого состояния и не иметь (паттерн хореография). Именно так выглядит архитектура больших систем со сложной бизнес логикой.
Зависит от реализации, есть фреймворки которые позволяют полностью абстрагироваться от хранилища и вызывать конструктор только один раз, как будто объект после создания и добавления в репозиторий все время находится в оперативной памяти а не в БД.
Тогда пользователь создаётся с неподтвержденным емейлом, публикуется событие User Created на которое реагирует сабскрайбер который шлёт на этот е-мейл письмо со ссылкой подтверждения. По клику на ссылке выполняется метод User::confirmEmail который помечает е-мейл пользователя как подтвержденный. И публикует событие Email Confirmed
Другой подписчик может на это событие инициировать проверку в списке фрода. Ендпойнт который получает результат проверки, если она выполняется асинхронно, передает его в метод User::completeRegistration который а зависимости от этого результата публикует либо событие Registration Completed либо Registration Failed.
Как видите вся доменная логика, которая касается регистрации пользователя, находится внутри объекта User несмотря на то что для принятия некоторых решений требуются данные из внешних источников.
Не обязательно, я бы сформулировал это требования так. "Нельзя создать пользователя, если его email уже используется другим пользователем". И самым логичным и надежным местом для проверки этого бизнес требования с моей точки зрения является конструктор объекта Пользователь. При таком подходе у нас нет никакой физической возможности в коде создать невалидного пользователя в системе. И такую проверку легко можно покрыть юнит тестом объекта Пользователь.
И в тоже время:
Я тут вижу явное противоречие. По моему тут описана как раз анемичная модель.
Оркестрация нужна когда в рамках одной большой бизнес транзакции меняется состояние нескольких доменных объектов. Есть специальные шаблоны предназначенные для этого, такие как сага или оркестратор, но ни тот ни другой не отражает актора, они отражают бизнес-процесс на уровне "если бизнес-объект А опубликовал событие B запусти метод С бизнес объекта D".
Я вообще не вижу для себя особого смысла в репрезентации акторов в виде программных модулей, кроме как в контексте проверки прав доступа к системе. Какая мне разница кто инициировал изменение состояния системы, реальный человек менеджер использующий GUI, или другая автоматическая система через API? Моя задача определить какая бизнес сущность отвечает за это состояние и передать ей соответствующую команду на изменение. По какому алгоритму это изменение происходит и какие проверки в ходе этого изменения нужно произвести знает только эта сущность, а не какой-то сервис который является внешним по отношению к этой сущности, и не должен знать ничего о ее внутренний реализации, в том числе и о том в каких атрибутах эта сущность хранит свое состояние. Для этого сервиса сущность это черный ящик с набором команд на изменение состояния. Да и по сути это даже не сервис доменного слоя, это сервис слоя приложения, который по определению не должен содержать никакой бизнес логики.
Естественно что приведенные фрагменты кода не моделирует весь бизнес процесс, а лишь один из его этапов. Каждый из этих этапов заключается в изменении состояния объекта Заказ по определенному алгоритму который включает в себя ряд проверок, является ли это состояние валидным. Мой вопрос состоит в том что мешает вам реализовать этот алгоритм внутри объекта, как это постулирует ООП и в частности богатая модель, а не снаружи, в сервисе который манипулирует состоянием этого объекта?
Ок, кажеться я вас понял и даже могу с вами в некоторой степени согласится. Действительно доменный сервис в виде набора процедур сгруппированных определенным образом наверное можно интерпретировать как субъекта, который взаимодействует с системой. Не буду спорить, если вам это облегчает понимание кода. Но я по прежнему не понимаю, что вам мешает при таком подходе использовать нормальное ООП с богатой доменной моделью. В чем принципиальная разница меду этими двумя фрагментами кода с точки зрения моделирования бизнес процесса:
Именно процедурный, ваши сервисы это ни что иное как наборы процедур которые оперируют структурами данных (анемичными моделями). Объектов в понимании ООП, у которых есть и состояние и поведение одновременно тут нет.
Т.е получается чтобы реализовать сценарий доставки заказа нам нужно два модуля: сервис курьера и сервис получателя заказа? И сервис курьера должен передать заказ сервису получателя заказа, ведь именно так все происходит а реальном мире, не так ли? Есть два актора один из которых передает товар а второй получает.
Программный код всего лишь моделирует реальный мир а не воспроизводит его в точности. При этом используются различные подходы. То что вы описали это процедурный подход и он имеет ряд недостатков по сравнению с объектным. Точно также можно сказать, что в реальном мире при доставке заказа менеджер не устанавливает значение доставлен в атрибут статус заказа и не сохраняет его в репозиторий, на самом деле это курьер передает заказ в руки клиента.
Да порог входа в DDD высокий, и разделить домен на ограниченные контексты с первого раза бывает непросто, но при правильном применении такой код гораздо проще поддерживать, чем клубок из сервисов, которые меняют состояние доменной модели кто во что горазд.
Без подробностей сказать трудно, но судя по всему в агрегате Order. Возможно в отдельном объекте Оплата Заказа.
А вы не могли бы привести какой нибудь конкретный пример, в котором DDD "рассыпалось пыль"? :)
Кое-кто считает вынесение бизнес-логики за пределы объекта анти-паттерном и нарушением принципов ООП https://martinfowler.com/bliki/AnemicDomainModel.html
Не уверен что репозиторий это хорошее место для помещения бизнес логики. Обычно интерфейс репозитория объявляется в слое домена или приложения, а реализация а слое инфраструктуры. Бизнес-логика не должна находиться в слое инфраструктуры. Да и ответственность репозитория должна быть ограничена сохранением и получением данных из хранилища, проверка бизнес-правил это ответственность слоя домена.
Добавил диаграмы.
Если нам важна целостность на уровне "каждой заявке в нашей системе должна соответствовать попытка создания заявки в банке", то мы не можем слать запрос в банк перед тем как наша заявка сохранится в базе, если не важна, то можем и тогда outside может иметь сайд эффект.
Хотя наверное вы правы, возможно в данном случае мы можем сделать заявку изнутри агрегата, конечно существует вероятность что после создания заявки в банке состояние агрегата может не сохранится, но в данном случае это может быть не критично, и упростит нам реализацию механизма резервации заявки. Т.е. outside может позволять изменение состояния внешних сервисов в ситуации если нам не критична целостность этого состояния по отношению к агрегату.
Метод nextIdentity() не изменяет состояния внешних сервисов, и роллбек транзакции в рамках которой осуществляется сохранение агрегата не приведет к нарушению целостности состояния ограниченного контекста.
Пример который я привел придуманный и сильно упрощен в целях демонстрации принципа outside а не работы банковской системы. Если перед принятием бизнес решения нужно изменить состояние внешней системы, то это изменение нужно производить за пределами агрегата, как реакцию на доменное событие. Грубо говоря, если мы можем узнать курс только как результат создания заявки в банке, то мы вначале создаем заявку в своем контексте без курса, только с информацией о банке в котором нужно сделать заявку, а запрос на создание заявки в банке шлем уже в подписчике на событие
BidCreated
, и в случае успеха записываем этот курс в методеBid::confirm(float $echangeRate, DateTime $expiresAt)
;если же по какой-то причине заявку в банке создать не удалось то выполняем метод
Bid::cancel()