Комментарии 95
Репозиторий != база данных.
Исходя из ваших вводных я бы сделал валидатор пользователя (кидать исключения в конструкторе пользователя не оч идея). В конструктор валидатора передавал бы пользователя и репозиторий. Валидация через вызов метода Validate. У репозитория должен быть метод - верни пользователя по имени.
Интерфейс репозитория объявляется в домене, реализация в инфраструктуром слое.
ps ну и понятно это всё надо в одной транзакции с сохранением делать, ну или ограничение уникальности имени пользователя в БД
using LanguageExt;
using static LanguageExt.Prelude;
public class UserWrapper
{
public readonly Either<string, User> user;
public UserWrapper() => user = Left<string, User>("empty");
public UserWrapper(Guid id, string name, string password, string email) => user = User.CreateUser(id, name, password, email);
public Either<string, User> GetUser()=> user;
public record User(Guid Id, string Name, string Password, string Email)
{
public static Either<string, User> CreateUser(Guid id, string name, string password, string email)=>
ValidateName(name)
.Bind(_ => ValidatePassword(password))
.Bind(_ => ValidateEmail(email))
.Map(_ => new User(id, name, password, email));
private static Either<string, Unit> ValidateName(string name)=>
string.IsNullOrWhiteSpace(name)
? Left<string, Unit>("Name is required")
: Right<string, Unit>(unit);
private static Either<string, Unit> ValidatePassword(string password)=>
string.IsNullOrWhiteSpace(password)
? Left<string, Unit>("Password is required")
: Right<string, Unit>(unit);
private static Either<string, Unit> ValidateEmail(string email)=>
string.IsNullOrWhiteSpace(email) || !email.Contains('@')
? Left<string, Unit>("Email is not valid")
: Right<string, Unit>(unit);
}
}
public class Program
{
public static void Main(string[] args)
{
var t = new UserWrapper();
Console.WriteLine(t.user);
}
}
тяжеловато жить без exceptions, да?
а на хаскеле не тяжеловато
import Control.Monad (when)
data User = User
{ userId :: Int
, userName :: String
, userEmail :: String
} deriving Show
createUser :: Int -> String -> String -> Either String User
createUser id name email = do
isValid <- validateUser name email
if isValid
then Right (User id name email)
else Left "Invalid user"
validateUser :: String -> String -> Either String Bool
validateUser name email = do
when (null name) (Left "Name is required")
when (not (containsAtSymbol email)) (Left "Email is not valid")
return True
where
containsAtSymbol = any (== '@')
main :: IO ()
main = do
let user1 = createUser 1 "John" "john@example.com"
let user2 = createUser 2 "" "invalidemail"
print user1 -- Right (User {userId = 1, userName = "John", userEmail = "john@example.com"})
print user2 -- Left "Invalid user"
а, да, проверка на уникальность
using LanguageExt;
using static LanguageExt.Prelude;
public class UserWrapper
{
public readonly Either<string, User> user;
public UserWrapper() => user = Left<string, User>("empty");
public UserWrapper(Guid id, string name, string password, string email) => user = User.CreateUser(id, name, password, email);
public Either<string, User> GetUser()=> user;
public record User(Guid Id, string Name, string Password, string Email)
{
private static readonly List<string> registeredEmails = new List<string>();
public static Either<string, User> CreateUser(Guid id, string name, string password, string email)=>
ValidateName(name)
.Bind(_ => ValidatePassword(password))
.Bind(_ => ValidateEmail(email))
.Bind(_ => IsUniqEmail(email))
.Map(_ => new User(id, name, password, email));
private static Either<string, Unit> ValidateName(string name)=>
string.IsNullOrWhiteSpace(name)
? Left<string, Unit>("Name is required")
: Right<string, Unit>(unit);
private static Either<string, Unit> ValidatePassword(string password)=>
string.IsNullOrWhiteSpace(password)
? Left<string, Unit>("Password is required")
: Right<string, Unit>(unit);
private static Either<string, Unit> ValidateEmail(string email) =>
string.IsNullOrWhiteSpace(email) || !email.Contains('@')
? Left<string, Unit>("Email is not valid")
: Right<string, Unit>(unit);
private static Either<string, Unit> IsUniqEmail(string email) =>
registeredEmails.Contains(email)
? Left<string, Unit>("Email is already taken")
: Right<string, Unit>(unit);
}
}
public class Program
{
public static void Main(string[] args)
{
var t = new UserWrapper();
Console.WriteLine(t.user);
}
}
Не тяжело. Мы используем CSharpFunctionalExtensions и не нужны никакие эксепшены, кроме встроенных. Вот эти Either как-то выглядят не очень по сравнению с Result<T> и Result
не получается
Ошибка CS1061 "Result<Unit, string>" не содержит определения "Bind", и не удалось найти доступный метод расширения "Bind", принимающий тип "Result<Unit, string>" в качестве первого аргумента (возможно, пропущена директива using или ссылка на сборку).
using CSharpFunctionalExtensions;
using CSharpFunctionalExtensions.ValueTasks;
public class UserWrapper
{
public readonly Result<User, string> user;
public UserWrapper() => user = Result.Failure<User, string>("empty");
public UserWrapper(Guid id, string name, string password, string email) => user = User.CreateUser(id, name, password, email);
public Result<User, string> GetUser() => user;
public record User(Guid Id, string Name, string Password, string Email)
{
private static readonly List<string> registeredEmails = new List<string>();
public static Result<User, string> CreateUser(Guid id, string name, string password, string email)
{
var result = ValidateField(name, "Name is required", string.IsNullOrWhiteSpace(name))
.Bind(() => ValidateField(password, "Password is required", string.IsNullOrWhiteSpace(password)))
.Bind(() => ValidateField(email, "Email is not valid", string.IsNullOrWhiteSpace(email) || !email.Contains('@')))
.Bind(() => ValidateField(email, "Email is already taken", registeredEmails.Contains(email)));
return result.Map(() => new User(id, name, password, email));
}
private static Result<Unit, string> ValidateField(string value, string errorMessage, bool condition)
{
return condition
? Result.Failure<Unit, string>(errorMessage)
: Result.Success<Unit, string>(Unit.Value);
}
}
}
public class Program
{
public static void Main(string[] args)
{
var t = new UserWrapper();
Console.WriteLine(t.user);
}
}
А зачем вам Unit? <T> - это возвращаемое значение.
ValidateField должен вернуть Result
Вы делаете либо Result.Failure(errorText), либо Result.Success(). И в конце возвращаете Result<User>. Сам Result уже содержит свойство с ошибкой - Error
заработало
using CSharpFunctionalExtensions;
using CSharpFunctionalExtensions.ValueTasks;
public class UserWrapper
{
public readonly Result<User, string> user;
public UserWrapper() => user = Result.Failure<User, string>("empty");
public UserWrapper(Guid id, string name, string password, string email) => user = User.CreateUser(id, name, password, email);
public Result<User, string> GetUser() => user;
public record User(Guid Id, string Name, string Password, string Email)
{
private static readonly List<string> registeredEmails = new List<string>();
public static Result<User, string> CreateUser(Guid id, string name, string password, string email)=>
ValidateField(name, "Name is required", string.IsNullOrWhiteSpace(name))
.Bind(nameValue => ValidateField(password, "Password is required", string.IsNullOrWhiteSpace(password)))
.Bind(passwordValue => ValidateField(email, "Email is not valid", string.IsNullOrWhiteSpace(email) || !email.Contains('@')))
.Bind(emailValue => ValidateField(emailValue, "Email is already taken", registeredEmails.Contains(emailValue)))
.Map(val => new User(id, name, password, email));
private static Result<string, string> ValidateField(string value, string errorMessage, bool condition)
{
return condition
? Result.Failure<string, string>(errorMessage)
: Result.Success<string, string>(value);
}
}
}
public class Program
{
public static void Main(string[] args)
{
var t = new UserWrapper(new Guid(),"","","");
Console.WriteLine(t.user);
}
}
там же есть ensure
ну да, хорошая библиотека
using CSharpFunctionalExtensions;
using CSharpFunctionalExtensions.ValueTasks;
using System;
using System.Collections.Generic;
public class UserWrapper
{
public readonly Result<User, string> user;
public UserWrapper() => user = Result.Failure<User, string>("empty");
public UserWrapper(Guid id, string name, string password, string email) => user = User.CreateUser(id, name, password, email);
public Result<User, string> GetUser() => user;
public record User(Guid Id, string Name, string Password, string Email)
{
private static readonly List<string> registeredEmails = new List<string>();
public static Result<User, string> CreateUser(Guid id, string name, string password, string email)=>
new User(id, name, password, email)
.ToResult("null")
.Ensure(_ => !string.IsNullOrWhiteSpace(name), "Name is required")
.Ensure(_ => !string.IsNullOrWhiteSpace(password), "Password is required")
.Ensure(_ => !string.IsNullOrWhiteSpace(email) && email.Contains('@'), "Email is not valid")
.Ensure(_ => !registeredEmails.Contains(email), "Email is already taken");
}
}
public class Program
{
public static void Main(string[] args)
{
var t = new UserWrapper(new Guid(), "", "", "");
Console.WriteLine(t.user);
}
}
![](https://habrastorage.org/getpro/habr/upload_files/371/ba7/55d/371ba755dbc9bd6c30d2664384e0c341.png)
Понимаю, что пользователи DDD скажут, что я сильно рискую, но я пользуюсь третьим вариантом. В бд обязательно будет проверка на уникальность по почту. Потому что хоть запроверяйся, без ограничения на БД может случиться гонка будет 2 пользователя.
DDD не запрещает сделать email уникальным на уровне базы для подстраховки, но наличие проверки в коде позволит:
Оставить описание требований к этой уникальности в доменном слое приложения.
Проведя явную проверку вернуть пользователю ошибку о нарушении конкретного правила, а не отлавливать исключения из базы и пытаться понять что же это за исключение и как обьяснять его пользователю.
В третьем варианте оно и есть, только в апп слое, т.к. доменного по сути и нет.
Т.е. требования уникальности есть в коде создания пользователя и ошибка точно так же возвращается. Особой разницы нет, кроме той, что кто-то может создать пользователя не выполняя проверку. Но тут ему прилетит ошибка от БД. Правда бывают более сложные инварианты, которые в БД уже так просто не проверишь.
Согласен с тем, что ошибку из бд конвертировать в пользовательскую не надо. Это вообще не тривиальная задача корректно отдать ошибку пользователю из нижележащих слоёв.
Так а какой смысо тогда делать это 2 раза? Или вы предлагаете исключение от базы просто как абстрактную ошибку показать? "Что то пошло не так"? Опять же если произошла ошибка после события UserCreated, будем делать событие компенсации? Писать ещё один обработчик? Не слишком ли сложно для такой простой операции?
Я согласен с автором комментария выше. Если у вас есть ограничения на базе а они почти всегда есть то нужно просто учиться обрабатывать исключения от базы и корректно их обрабатывать. Иначе вы просто вынуждены дублировать функционал обработки ошибок.
Мир не идеален
Все круто с вашим событием до того момента как по какой-то причине ваш паблишер станет асинхронным (случается по разным причинам, иногда, например, из-за "оптимизаций" от коллег)
В классе User вызов функции
AddDomainEvent(new UserWasCreated(Id, Email)); // детали реализации скрыты
Откуда эта функция, т.е. какой класс хранит ее реализацию, прям сам класс User?
Здравствуйте. В интернете сможете найти различные варианты реализации. Если необходимо, могу ссылки покидать. Как делаем мы в команде:
У нас есть BaseEntity, IAggregateRoot.
В инете в основном в одном из вариантов реализации методы событий лежат в абстракции BaseEntity. Так как мы работаем с паттерном аггрегата, то делаем еще одну абстракцию: BaseAggregateRoot, в которую и помещается маркер. И уже все наши агрегаты наследуем от нее, чтобы сами entity не могли события добавлять.
20 лет назад написали бы отдельную функцию is_unique_email(check_mail).
10 лет назад вынесли бы в Сервисный слой, ну, чтобы Пользователи отдельно - котлеты отдельно.
Сейчас даже развесистой и основательной статьи недостаточно, чтобы понять что, куда и зачем. Причём кол-во кода начинает стремительно увеличивать энтропию Вселенной, которая и без того велика.
нужно гарантированно не пронести в домен невалидную сущность
а как именно - выбирайте
я бы сделал CREATE UNIQUE INDEX IX_Users_Email ON Users (Email);
и не выеживался
Мы такую проблему решаем вынося всю логику касающуюся более чем одной сущности в доменные сервисы.
Но я бы делал не просто сервис проверки уникальности Email, а сразу сервис создания юзера, который бы проверял наличие дублей в репозитории еще до создания самой сущности.
<?php
class CreateUserService
{
UserRepositoryInterface repository
handle(string name, string password, string email)
{
# У нас нет никакой необходимости получать юезра из репозиятория, достаточно спросить о его существовании
if (repository.containtUserWith(email)) {
throw new BusinessException.EmailIsNotUnique();
}
usersRepository.save(new User(request.Name, request.Password, request.Email));
}
}
Что мне определенно не нравится в предложенном варианте c ивентами:
Доменный ивент отправляется до комита транзакции.
Доменный ивент содержит невалидную сущность.
К примеру захотев отправить юзеру email-нотификашку о регистрации, логично бьло бы делать это в ответ на доменное событие создания юзера, но у нас этот ивент не является свидельством создания юзера, а лишь свидетельством попытки.
Вместо того чтобы предотвраить перевод системы в невалидное состояние, мы разрешаем ей к нему придти, и только потом проверяем всё ли в порядке и в случае чего пытаемся исправить ситуацию.
так красивше
using LanguageExt;
using static LanguageExt.Prelude;
public record User(Guid Id, string Name, string Password, string Email)
{
public static Either<string, User> CreateUser(string name, string password, string email)
=> ValidateName(name)
.Bind(_ => ValidatePassword(password))
.Bind(_ => ValidateEmail(email))
.Map(_ => new User(Guid.NewGuid(), name, password, email));
private static Either<string, Unit> ValidateName(string name)
=> string.IsNullOrWhiteSpace(name)
? "Name is required"
: Right(unit);
private static Either<string, Unit> ValidatePassword(string password)
=> string.IsNullOrWhiteSpace(password)
? "Password is required"
: Right(unit);
private static Either<string, Unit> ValidateEmail(string email)
=> string.IsNullOrWhiteSpace(email) || !email.Contains('@')
? "Email is not valid"
: Right(unit);
}
В нашем домене больше нет абстрактных служб. Вместо них вполне себе реальные события, которые происходят в жизни.
теперь вы притащили состояние в домен
это нехорошо, лучше бы состояние оставить во внешнем контексте, т.е. в репозитории
Нигде и никогда бизнес-пользователи \не читают код. Для пользователя абсолютно неважно в каком методе у вас проверка уникальности.
В реальной жизни будет ключ уникальности в базе, если базу создаете вы сами, то не сделать ключ уникальности - преступление.
Эвенты это неявный вызов, что гораздо хуже, чем явный вызов. Потому что эвенты из вызывающего кода не читаются никак и рано или поздно случится ситуация, когда эвент просто не вызовется.
У вас же UI или WebAPI вызывает в итоге какую-то функцию (контроллер\команда\handler), она свою очередь вызывает репозиторий и выполняет сохранение пользователя. Вот в эту функцию и добавьте проверку уникальности перед тем как добавлять.
Это будет явный вызов, очень читаемый и оптимальный, так как для проверки уникальности можно сделать оптимальный запрос.
Такая реализация не отменяет п2 про уникальность, потому что при всех оптимистичных проверках, не блокируя всю таблицу, можно сделать дубль.
Во многом с вами согласен. Про имейл - пример как аналогия, абстрактный. То, что вы говорите про контроллер, который вызывается в другую функцию, это 3 вариант, описанный мной. Вариант рабочий. Но когда пример более сложный, начинаешь задумываться.
1. Вот на картинке на превью кажется, что слой Application не должен ссылаться на репозитории, хотя он ссылается, даже в вашем примере. Может картинка эта не отсюда?
2. Кейс с зависимостью доменного объекта от интерфейса типа IUniqueChecker мне не кажется проблемой. Ты говоришь, что должна выполниться проверка, это доменное знание, а её реализация это уже знание уровня приложения.
Ваш сценарий с доменными событиями имеет недостаток - вместо одного места валидации - она расползается по приложению. Код кажется менее читаемый будет.
Может ссылаться и ссылается, может, я неправильно вас понимаю?
да, в ваших словах есть логика. Но у такого подхода есть недостаток. Вижу, я не совсем легко и доступно объяснил, поэтому можете почитать про трилемму в ddd
Что касается событий, да, есть такое. Но мы достигаем слабой связности и других преимуществ, которые зачастую перевешивают это. В любом случае, проблема в том, что проблема не решается))
Прочитал все комментарии, но ни кто не вспомнил, что пользователь всегда сможет обойти проверку на уникальность email.
С помощью +
в самом адресе и в некоторых случаях забывают про регистр.
Можно обойтись регистрацией отдельного ящика.
И тут появляется вопрос к бизнесу: а имеет ли смысл, в таком случае, следить за уникальностью email?
Имеет, если email это логин. Также, на почту могут приходить всякие уведомления и, если есть 2 пользователя с одной почтой, то они будут видеть уведомления друг друга, что может вызвать путаницу. Например, остаток средств на счёте. (Кстати, в таких письмах не редко бывает ссылка на оплату, а оплатить чужой счёт было бы неприятно)
Я за вариант 4. Самый удобный и читаемый, но я обычно сервисы не держу в доменном слое, все сервисы для бизнес логики - в бизнес слое (Application).
Может я в чем-то ошибаюсь, будет интересно почитать другие мнения, почему сервис проверки уникальности почты/репозитории/др. должны находиться в Domain слое, а не в Application.
Знаете, можно по-разному. К сожалению или к счастью, теория не настолько хороша, чтобы быть универсальной и полноценной. Как всегда, все зависит от команды и проекта.
ну а доменный сервис - это все же доменный именно сервис, поскольку в нем есть бизнес-логика, которая должна оставаться на уровне домена. Если вы захотите сделать новое приложение (например, приложение для десктопа и веба у вас отличаются по тем же бизнес требованиям), то вы не размазываете эту логику по приложению, а храните все в домене для поддержания консистентности модели
А как вам таоке решение: создаем интефейс в доменном слое, которыей описывает все потребности агрегата во внешних данных, и реализуем его в слое инфраструктуры. Такой интерфейс легко мокать в юнит тестах. И агрегату ничего не нужно знать о том откуда эти данные берутся, из репозитория, проэкции или вообще из какого-то внешнего сервиса.
<?php
#Domain
interface UserOutsideInterface
{
public function isEmailUnique(string $email): bool;
}
final class User{
public function __construct(
private readonly UserOutsideInterface $outside,
private string $email,
)
{
if (!$this->outside->isEmailUnique($this->email)) {
throw new EmailAlreadyExistsException();
}
}
}
#Infrastructure
final class DefaultUserOutside implements UserOutsideInterface
{
public function __construct(
private UserRepository $repository
{
}
public function isEmailUnique(string $email): bool
{
return $this->repository->hasWithEmail($email);
}
}
Отличное решение, по сути, тот же репозиторий, только называется по-другому
Не совсем, в данном случае агрегат не имеет доступа ко всем методам репозитория, и не имеет практической возможности вытащить другой агрегат и выполнить на нем бизнес операцию, кроме того в реализации этого интерфейса можно враппить и другие сервисы, которые предоставляют данные агрегату для принятия бизнес решений. Причем это агрегат описывает формат данных который ему нужен, таким образом он никак не зависит от этих внешних сервисов. Более подробно этот подход описан тут https://gitlab.com/grifix/sandbox/-/blob/main/GUIDELINE.md?ref_type=heads#aggregate-outside
Спасибо, сейчас ознакомлюсь. Хотя я прекрасно знаю, что это. Мы тоже так делаем. Только мы в отдельном файле все контракты храним, чтобы следовать правилу единой ответственности. Ну, типа ITicketSystemGateway, INotificationService.
Это не совсем то, хотя очень похоже :). Ответственностью тех сервисов которые вы описали является коммуникация с конкретной внешней службой. У outside немного другая ответственность. Он является каки бы anticorruption layer между агрегатом и теми внешними службами которые нужны агрегату для принятия решения. Outside ограничивает доступ агрегата только к тем методам которые ему нужны, и не позволяет агрегату выполнять методы с сайд эффектами. Плюс отсекает лишние данные которые не нужны агрегату.
Предположим для принятия решения о скидке нам нужно информация о стоимости товара. В случае если мы внедрим внешний сервис IProductsService::getProduct($productid):ProductDto
напрямую в агрегат он нам вернет всю информацию о товаре, включая название, размер и т.п. В случае с outside мы отсекаем все что нам не нужно и возвращаем только цену IOutside::getProdcutPirce($productId):int
. Дополнительным бенефитом является то что при тестировании во втором случае нам нужно замокать только значение int а не целый ProductDto
Вы правы, различия есть. Почитал. Никогда не слышал об этом паттерне. Ценная информация. Спасибо!
Для нас этот паттерн имеет еще одно преимущество. На этапе имплементации бизнес логики, нам не нужно задумываться о том откуда конкретно мы будем получать внешние данные для принятия решений. Грубо говоря первым этапом реализации бизнес операции является написания логики внутри агрегата и объявление интерфейса outside если эта логика требует внешних данных. В процессе разработки агрегат покрывается юнит тестом, который покрывает все edge cases фичи (в отличии от подхода с проверками в слое Application, отдельных доменных сервисах или обработчиках событий), а его outside просто мокается.
После того как разработка и тестирование доменного слоя закончена, в рамках интеграционного тестирования outside подключается к уже существующим сервисам-коннекторам или создаются новые сервисы-коннекторы типа ITicketSystemGateway.
Вот пример такого теста с моком https://gitlab.com/grifix/sandbox/-/blob/main/GUIDELINE.md?ref_type=heads#unit-tests
Я понимаю. Это интересно. Своего рода гибридное решение. Кстати, того же мы достигаем с интерфейсами evendhandlers в доменном слое для тех же тестов
Мне кажется что event handler должен находится в слое Application. Ну и я согласен с @DmitriyGordinskiy который указывает на потенциальную проблему в случае с проверками в ивент хендлерах. С моей точки зрения если агрегат опубликовал событие, это означает что его состояние изменилось и оно должно быть валидным.
Есть еще одно решение, можно воспользоваться типами. Т.е. в системе у нас курсирует сущность User, но репозиторий на сохранение принимает только ValidUser. Тогда всю валидацию можно вынести в доменный сервис и спать спокойно, никто не сможет забыть провалидировать пользователя перед сохранением.
Интересный подход. Но если валидация происходит в доменном сервисе то что нам мешает создать сразу ValidUser и добавить его в репозиторий забыв провалидировать через доменный сервис?
Я пришёл к выводу, что ничего не мешает программисту всё сломать. Будет какая-то валидация в домене мешать, выпилит.
Но программисты обычно хотят всё сделать правильно и нужно им в этом помочь.
Программист видит что репозитарий принимает ValidUser, программист ищет где и как ValidUser создаётся, программист находит нужный сервис, программист использует сервис.
PROFIT!
Возможно, но в случае с валидацией внутри агрегата, программисту нужно приложить меньше усилий, соответственно и вероятность ошибки ниже. Ему просто нужно создать агрегат и поместить его в репозиторий, либо получить агрегат из репозитория и изменить его состояние вызвав соответствующий метод. Не нужно помнить ни о каких сервисах или valid версиях агрегата. И нет никакой практической возможности привести агрегат в невалидное состояние извне.
Согласен, риски ниже. Но можно залезть в агрегат и всё сломать. Как ни крути, где-то в коде будет место, где мы следим за своими инвариантами и пока у программиста есть доступ к коду, его ничего не остановит. В конце концов можно даже не трогать старый код, а создать новый метод репозитария и сохранять через него завив на все проверки. Например, в задаче массового импорта пользователя.
Единственная защита - это физически запретить доступ к коду создания пользователя. Например, завернуть в микросервис. Но тогда, мы опять таки должны быть уверены, что программисты самого микросервиса пользователей в адеквате.
Возможно мне везёт, но у меня сложилось ощущение, что попытки навернуть защиту от "дурака" в коде не дают особого профита. Становится чуть безопаснее и сильно сложнее. А потом это ещё поддерживать.
Не нужно помнить ни о каких сервисах или valid версиях агрегата
На самом деле это не такая большая проблема. Просто тот кто фигачит в анемичном стиле понимает, что где-то есть валидатор и можно обратиться к нему.
Возможно, но если мы следуем ООП, то мне кажется что подход с проверкой в агрегате является более правильным, так как в данном подходе объект сам контролирует свои инварианты.
По поводу сложности я бы тоже поспорил :). ООП как раз и было придумано для борьбы со сложностью. И на мой взгляд иметь всю бизнес логику которая касается поведения пользователя внутри самого пользователя, а не разбросанной по разным сервисам, гораздо удобнее в плане поддержки и расширения.
Всегда стеснялся спросить, а не превращается ли тогда агрегат в огромного монстра? Что если у сущности не одно валидное состояние, а три. Воткнем еще логику перехода по состояниям, возможные операции по изменению полей не влияющих на состояние. И все в один файл?
Да, такой риск существует, именно для решения этой проблемы в DDD существуют ограниченные контексты. https://martinfowler.com/bliki/BoundedContext.html
Кроме этого, даже в рамках одного ограниченного контекста агрегат можно разбить на value objects, каждому из которых можно делегировать часть бизнес логики.
Мне нравится идея, что объект сам поддерживает свои инварианты. Мне реализация не нравится: сложно и один фиг нужны сервисы, когда дело касается нескольких агрегатов. Ещё валидацию приходится повторять дважды: в юскейса и агрегате. Иначе ошибки нормально из апи не отдашь.
Решение для себя нашёл в модулях: контексты поменьше и работы с объектами только через фасад. Аля микросервисы, только одном сервисе.
Докину еще вариант в тред обсуждения самовалидируемости сущностей и необходимости подключения репозитория для проверки уникальности email.
Я для себя решил эту дилему перестав называть юзера с дублирующим email невалидным.
Юзер валидный, но его сохранение приведет систему к невалидному состоянию чего мы и пытаемя избежать.
Это же касается других правил которые нельзя првоерить в рамках одной сущности\агрегата.
Продолжение тематики, обсуждаемой в этой статье, смотрите в статье "Валидация данных на уровне бизнес-логики приложения".
Неплохая статья. Но про вариант 5 с доменными ивентами есть замечания:
1. Не сказано, что сохранение юзера и хендлер должны работать в одной транзакции. И хендлер должен вызываться до вставки юзера.
2. Нет реализации отправки ивента. Автор статьи где-то писал "в тырнете много вариантов", но лучше было показать свой вариант
3. Про ивенты не сказано что проверка email на уникальность становится гораздо менее очевидной
Интересная статья. Спасибо за материал. Но все-таки пример здесь довольно простой. Я думаю, что конкретно в данном контексте лучший вариант был бы иным:
Объявить конструктор в сущности как package-private (так это называется в Java, возможно, в C# по-другому)
Добавить рядом доменный сервис UserCreateService
В нем расположить логику по проверки уникального email-а при создании пользователя
Таким образом, приложение может использовать лишь UserCreateService, не имея доступа к созданию User напрямую. Тогда и можно гарантировать, что Use Case всегда будет соблюдаться.
Все-таки доменное событие обычно используется, чтобы сообщить внешнему миру, что в агрегате произошли какие-то изменения. То есть теоретически у события может быть несколько обработчиков. А судя по описанному кейсу, предполагается, что обработчик всегда один и тот же.
Просто мы тоже сначала именно так проверяли уникальность названий. Но в конце концов, в этом стало тяжело разбираться.
P.S. А вообще, эта проблема возникает только в тех ситуациях, когда User – корень агрегата. Если предположить, что был бы UserGroup, в рамках которой email-ы должны быть уникальны, то тогда достаточно было бы предоставить методы UserGroup.createUser(...), внутри которого и проверялся бы email
Решил более подробно описать свой вариант 6 который мы обсуждали в комментариях https://habr.com/ru/articles/799019/
Мне кажется типичная проблема тут в том, что изменение требований может приводить к изменению архитетуры и предсказать такое изменение нельзя, нужно просто реструктурировать объекты.
Пользователь может отвечать только за данные пользователя, он может проверить почту только на корректность строки и внутренние правила.
Бизнес правило для многих пользователей (уникальность) должна делать сущность, ответственная за многих пользователей, в данном случае репозиторий.
Соответственно функция должна сменить владельца на следующего минимально знающего - того, кто знает про обе сущности, пользователя и "список пользователей" (репозиторий). Репозиторий выглядит хорошим кандидатом.
Не уверен что репозиторий это хорошее место для помещения бизнес логики. Обычно интерфейс репозитория объявляется в слое домена или приложения, а реализация а слое инфраструктуры. Бизнес-логика не должна находиться в слое инфраструктуры. Да и ответственность репозитория должна быть ограничена сохранением и получением данных из хранилища, проверка бизнес-правил это ответственность слоя домена.
А я в описанном примере вижу другую проблему. В приложении есть слой для объектов с состоянием, но нет слоя для бизнес-логики. Т.е. проблема возникла тогда, когда бизнес-логику втащили с объект доменной модели. И если для анемичных правил (типа непустого имени) это ещё может быть корректно, то для правил, выходящих за границы объекта, это точно некорректно.
Что делать - понятно. Выносить бизнес-логику в отдельный слой. Остаётся только вопрос - когда она будет вызвана. И тут очевидно, что должно быть событие/действие, в рамках которого работает бизнес-логика. Но тут важно одно уточнение. По идее никаких действий с объектами домена приложения не должно выполняться в обход бизнес-логики. И тут многие идут "упрощением", втаскивая бизнес-логику внутрь объекта. Тогда как правильно, чтобы все действия изначально были бы в слое бизнес-логики. Иначе говоря, в случае веб-сайта из контроллера вызов идёт в сервис слоя бизнес-логики. И если уж нужна валидация почты - оно должно делаться где-то там и до сохранения данных с БД.
Всё это изрядно пересекается с принципами SOLID. И все представленные в статье варианты нарушают те или иные принципы. Я на одном из проектов реализовал иной подход. Основой стала мысль о том, что бизнес-правила в общем случаем формулируются однажды, а работать должны всегда. Т.е. в любой момент времени может прийти новое требование, которое должно работать и в новом коде и в старом. Перелопачивать горы кода мне, конечно, лень. Поэтому в том проекте были реализован механизм валидаторов правил. При этом необходимость выполнения требований навешивались атрибутами на методы сервисов бизнес-логики, а выполнялись сервисом валидации, который вызывался интерсептором до вызова метода. В атрибуте указывался тип операции с объектом (из множества операций, связанных с типом) и сам объект (ссылка на параметр метода). Сервис же валидации находил все зарегистрированные требования для типа объекта и типа операции и вызывал проверку всех требований. Как итог, все бизнес-правила лежали в одном месте и их список можно было спокойно расширять. Объекты доменной модели вообще ничего не знали про логику, а сервисы бизнес-логики не вылезали за границы своей ответственности.
Кое-кто считает вынесение бизнес-логики за пределы объекта анти-паттерном и нарушением принципов ООП https://martinfowler.com/bliki/AnemicDomainModel.html
Проблема в том, Rich Domain Model это бутафория. Возьмём какой-нибудь классический пример, интернет-магазин. Объект "Заказ". По DDD, мы должны определить класс Order и всё, что можно с ним сделать, находится в этом классе. Вся бизнес-логика. Типа. Почему "типа"? Да потому что класс Order это никакой не "Заказ" и никакой не бизнес-объект, это по сути одна маленькая грань настоящего заказа, это набор полей в одной конкретной системе. В реальном же мире заказ это далеко не только запись в БД, и логикой одной приложухи дело совсем не ограничивается. Заказ может иметь даже не сотню, а тысячи сложнейших правил, некоторые вообще существуют не больше недели.
Я наблюдал, как красивое с чистое DDD-проектирование разбивалось не просто в дребезги об реальность, а в пыль, унижая всю инженерию в целом. Количество кода на простейшие задачи и правила выходят за рамки всех разумных границ. Какие красивые доменные сервисы, DDD-объекты с бизнес-правилами, репозитории, команды, гейты, события. Но отойти на пару шагов, и мы видим как красиво, изящно и мастерски, десятки человек вкручивают одну лампочку в течение месяца.
А если перестать фантазировать, и осознать, что мы управляем не Заказом, а всего лишь записью о заказе в БД, т.е. набором полей и связанных записей, то оказывается, что в основном мы работаем с состоянием. А не рулим и не описываем "правила бизнеса". Мы читаем из БД объект, показываем срез данных юзеру, принимаем ввод от юзера, проверяем что он ввёл, сравниваем с данными в БД и сохраняем новое состояние. Вот что мы делаем. А не пишем манускрипты с "правилами бизнеса" :)
Самые лучшие решения, которые мне удавалось увидеть или создать, это передача контроля и программирования правил бизнеса на "аутсорс", т.е. специалисту внедрения. Написание высшей бизнес-логики в классах, это обезьяний труд, обесценивание инженерной дисциплины.
Скажите, а оплата заказа в интернет-магазине с использованием промо-кода и накопленных бонусов, особенно, если сама оплата делается при получении заказа через терминал у курьера, она в каком объекте доменной модели должна быть? Order? User? что-то ещё?
Без подробностей сказать трудно, но судя по всему в агрегате Order. Возможно в отдельном объекте Оплата Заказа.
Ну или в чём-то типа PaymentManager (service/processor/ etc).
Иначе говоря, в реальном мире всем понятно, что заказ себя не оформляет, не оплачивает, не доставляет. И даже User сам себя не регистрирует. Этим всем занимаются манагеры, сиречь уполномоченные субъекты, они же - сервисы доменной модели.
А вот как раз сервисы имеют полное право взаимодействовать с кучей сущностей и других сервисов - всё, что будет полезно для выполнения функции. А объекты доменной модели должны оставаться существительными.
Программный код всего лишь моделирует реальный мир а не воспроизводит его в точности. При этом используются различные подходы. То что вы описали это процедурный подход и он имеет ряд недостатков по сравнению с объектным. Точно также можно сказать, что в реальном мире при доставке заказа менеджер не устанавливает значение доставлен в атрибут статус заказа и не сохраняет его в репозиторий, на самом деле это курьер передает заказ в руки клиента.
Не, тут вопрос не в точном воспроизведении. И, конечно, это не процедурный подход.
Важный момент, который многие забывают, когда пытаются реализовать DDD - это то, что в бизнес-процессе есть объекты, а есть субъекты.
В программной модели и то и другое реализуется через объекты, но они разные.
Именно процедурный, ваши сервисы это ни что иное как наборы процедур которые оперируют структурами данных (анемичными моделями). Объектов в понимании ООП, у которых есть и состояние и поведение одновременно тут нет.
Ок, кажеться я вас понял и даже могу с вами в некоторой степени согласится. Действительно доменный сервис в виде набора процедур сгруппированных определенным образом наверное можно интерпретировать как субъекта, который взаимодействует с системой. Не буду спорить, если вам это облегчает понимание кода. Но я по прежнему не понимаю, что вам мешает при таком подходе использовать нормальное ООП с богатой доменной моделью. В чем принципиальная разница меду этими двумя фрагментами кода с точки зрения моделирования бизнес процесса:
<?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(); //вся логика по доставке находится внутри объекта
}
}
В чем принципиальная разница меду этими двумя фрагментами кода с точки зрения моделирования бизнес процесса
С мой точки зрения оба фрагмента не моделируют бизнес-процесс.
Потому что в реальности всю будет гораздо сложнее. Вы ведь заказывали в интернет-магазинах товары с доставкой? Примерно помните, как выглядит фасад бизнес-процесса? Собрав корзину вы нажимаете кнопку "оформить заказ", после чего выбирает опции доставки и оплаты. Потом нажимаете "подтвердить" и вот тут начинается бизнес-процесс. Возьмём, к примеру, заказ в wildberries с доставкой в ПВЗ и оплатой с привязанной карты после получения товара покупателем. В модели этого бизнес-процесс будет три этапа (упрощённо): оформление доставки, доставка товара в ПВЗ, акцепт получения тех позиций, которые покупатель забрал. На первом этапе будет выполнено аллоцирование товара у продавца, будет создан заказ-наряд на доставку (точнее множество, если в корзине не один товар). На втором служба доставки сначала заберёт товар у продавца, потом привезёт в ПВЗ, где сотрудник подтвердит приёмку товара (а покупатель в ЛК будет видеть все этапы доставки). На третьем покупатель предъявит в ПВЗ код получения, сотрудник выдаст товары, покупатель проверит целостность, что не сломалось, что заберёт, другое вернёт, после чего должна сработать оплата одних товаров и запущен процесс возврата других.
И это ещё не всё, а так, процентов 10 от реальной сложности бизнес-процесса. И вот теперь вопрос - чем в этом бизнес-процессе является заказ? Кто выполняет все указанные действия? Одного $order->deliver();
тут точно не хватит. Кто и когда будет делать всё остальное?
Естественно что приведенные фрагменты кода не моделирует весь бизнес процесс, а лишь один из его этапов. Каждый из этих этапов заключается в изменении состояния объекта Заказ по определенному алгоритму который включает в себя ряд проверок, является ли это состояние валидным. Мой вопрос состоит в том что мешает вам реализовать этот алгоритм внутри объекта, как это постулирует ООП и в частности богатая модель, а не снаружи, в сервисе который манипулирует состоянием этого объекта?
Да никто не мешает всё делать в рамках ООП (лично я именно так и делаю). Но я вам пытаюсь показать, что на каждом этапе бизнес-процесса участвуют более одного объекта доменной модели. "изменении состояния объекта Заказ по определенному алгоритму " - это лишь запчасть бизнес-процесса. Ну не может заказ рулить всеми элементами бизнес-процесса. И курьерская служба не должна рулить состоянием заказа, т.к., грубо говоря, у курьера задача взять коробку из пункта А и привезти в пункт Б. И т.д.
Я не говорю, что объект заказ должен быть анемичным. Но с точки зрения классов в ООП, кто-то является заказом, а кто-то должен олицетворять элементы бизнес-процесса, а кто-то оркестрировать всем этим ансамблем.
Да никто не мешает всё делать в рамках ООП (лично я именно так и делаю).
Я не говорю, что объект заказ должен быть анемичным.
И в тоже время:
Объекты доменной модели вообще ничего не знали про логику, а сервисы бизнес-логики не вылезали за границы своей ответственности.
Я тут вижу явное противоречие. По моему тут описана как раз анемичная модель.
Но с точки зрения классов в ООП, кто-то является заказом, а кто-то должен олицетворять элементы бизнес-процесса, а кто-то оркестрировать всем этим ансамблем.
Оркестрация нужна когда в рамках одной большой бизнес транзакции меняется состояние нескольких доменных объектов. Есть специальные шаблоны предназначенные для этого, такие как сага или оркестратор, но ни тот ни другой не отражает актора, они отражают бизнес-процесс на уровне "если бизнес-объект А опубликовал событие B запусти метод С бизнес объекта D".
Я вообще не вижу для себя особого смысла в репрезентации акторов в виде программных модулей, кроме как в контексте проверки прав доступа к системе. Какая мне разница кто инициировал изменение состояния системы, реальный человек менеджер использующий GUI, или другая автоматическая система через API? Моя задача определить какая бизнес сущность отвечает за это состояние и передать ей соответствующую команду на изменение. По какому алгоритму это изменение происходит и какие проверки в ходе этого изменения нужно произвести знает только эта сущность, а не какой-то сервис который является внешним по отношению к этой сущности, и не должен знать ничего о ее внутренний реализации, в том числе и о том в каких атрибутах эта сущность хранит свое состояние. Для этого сервиса сущность это черный ящик с набором команд на изменение состояния. Да и по сути это даже не сервис доменного слоя, это сервис слоя приложения, который по определению не должен содержать никакой бизнес логики.
Я тут вижу явное противоречие.
Я не говорю, что в объектах ничего не было, я говорю, что они не содержали посторонней бизнес-логики.
Есть специальные шаблоны предназначенные для этого, такие как сага или оркестратор
Тут уже вопрос в инструменте. Можно и сагу запилить, а можно и OrderProcessingService. В любом случае в объекте Order не будет операций бизнес-процесса.
И если уж вернуться к теме топика, то можно понять, требование уникальности email - это внешнее требование, а потому не должно быть внутри нашего объекта User.
И если уж вернуться к теме топика, то можно понять, требование уникальности email - это внешнее требование, а потому не должно быть внутри нашего объекта User.
Не обязательно, я бы сформулировал это требования так. "Нельзя создать пользователя, если его email уже используется другим пользователем". И самым логичным и надежным местом для проверки этого бизнес требования с моей точки зрения является конструктор объекта Пользователь. При таком подходе у нас нет никакой физической возможности в коде создать невалидного пользователя в системе. И такую проверку легко можно покрыть юнит тестом объекта Пользователь.
И вот тут тонкая грань между "создать пользователя" и "создать объект типа Пользователь". Конструктор в нашем приложении может много где вызываться. И даже при вычитывании пользователя из БД. Потому конструктор класса - точно не подходит. А где же тогда проверять бизнес-правила?.. Ну, где-то перед тем, как мы его в БД создадим? Ну, тут, как бы можно даже constraint на таблицу повесить. А если будет требование подтверждения того, что email принадлежит пользователю? То после записи в БД (с проверкой уникальности) надо будет ему ещё письмо послать и подождать, пока он ссылку из письма откроет, а это кто сделает? А если ещё надо проверить, нет ли этого email или комбинации фио+возраст+город в списках фрода?
Список можно бесконечно продолжать. И тут дело не в моей фантазии, а в том, что так живёт бизнес - там всё время что-то новое придумывают. И во множестве реальных ситуация рано или поздно становится понятно, что бизнес-правила - это одном, а низкоуровневая программная модель - это другое. И тут уж хоть саги городи, хоть хитроумные валидаторы, хоть сервисы. Но вот точно, держать всё эту кухню внутри классов, моделирующих объекты доменной модели, уже не получится.
Конструктор в нашем приложении может много где вызываться. И даже при вычитывании пользователя из БД. Потому конструктор класса - точно не подходит.
Зависит от реализации, есть фреймворки которые позволяют полностью абстрагироваться от хранилища и вызывать конструктор только один раз, как будто объект после создания и добавления в репозиторий все время находится в оперативной памяти а не в БД.
А если будет требование подтверждения того, что email принадлежит пользователю? То после записи в БД (с проверкой уникальности) надо будет ему ещё письмо послать и подождать, пока он ссылку из письма откроет, а это кто сделает? А если ещё надо проверить, нет ли этого email или комбинации фио+возраст+город в списках фрода?
Тогда пользователь создаётся с неподтвержденным емейлом, публикуется событие User Created на которое реагирует сабскрайбер который шлёт на этот е-мейл письмо со ссылкой подтверждения. По клику на ссылке выполняется метод User::confirmEmail который помечает е-мейл пользователя как подтвержденный. И публикует событие Email Confirmed
Другой подписчик может на это событие инициировать проверку в списке фрода. Ендпойнт который получает результат проверки, если она выполняется асинхронно, передает его в метод User::completeRegistration который а зависимости от этого результата публикует либо событие Registration Completed либо Registration Failed.
Как видите вся доменная логика, которая касается регистрации пользователя, находится внутри объекта User несмотря на то что для принятия некоторых решений требуются данные из внешних источников.
вся доменная логика, которая касается регистрации пользователя, находится внутри объекта User
Вы уверены? Если для без подтверждения email-а пользователь ничего делать не может, то он вроде как и не зарегистрировался. И наверняка, чуть позже другой пользователь попробует зарегистрироваться с этим email-ом, подтвердить его и только тогда его регистрация завершится. Кстати, на многих сайтах всё именно так и работает. А порой список действий, который должны быть выполнены, прежде пользователь станет таки зарегистрированным, ещё больше
Таким образом, вопрос в том, где у вас проходит граница бизнес-логики. Если в конкретном случае она помещается в рамках одной транзакции работы с объектом - то ок, может пробовать запихать внутрь. Но в достаточно большой системе (достаточной для того, чтобы проектировать её согласно DDD) достаточно часто так не будет получаться (достаточно для того, чтобы изначально делать такую архитектуру, где бизнес-логика реализуется вне доменных объектов)
Вы уверены? Если для без подтверждения email-а пользователь ничего делать не может, то он вроде как и не зарегистрировался. И наверняка, чуть позже другой пользователь попробует зарегистрироваться с этим email-ом, подтвердить его и только тогда его регистрация завершится. Кстати, на многих сайтах всё именно так и работает. А порой список действий, который должны быть выполнены, прежде пользователь станет таки зарегистрированным, ещё больше
Вариантов решения может быть несколько. Например мы можем на первом этапе создавать не объект Пользователя, а объект Регистрация, с ограниченным сроком действия. А пользователя создавать только когда эта Регистрация опубликует событие что она успешна. Или другой вариант: мы можем удалять пользователей с не подтвержденным е-мейлом через какое то время. Не вижу причины почему такой подход не может использоваться для бизнес процесса с большим количеством шагов.
Таким образом, вопрос в том, где у вас проходит граница бизнес-логики. Если в конкретном случае она помещается в рамках одной транзакции работы с объектом - то ок, может пробовать запихать внутрь. Но в достаточно большой системе (достаточной для того, чтобы проектировать её согласно DDD) достаточно часто так не будет получаться (достаточно для того, чтобы изначально делать такую архитектуру, где бизнес-логика реализуется вне доменных объектов)
Любой многоэтапный бизнес процесс состоит из серии команд, каждая из которых изменяет состояние бизнес объекта или группы объектов. Алгоритм изменения состояния этого объекта согласно ООП должен быть реализован внутри этого объекта. Если бизнес процесс затрагивает изменение состояния нескольких объектов, либо несколько последовательных изменений состояния одного объекта то часть логики может находится вне этих объектов, но эта логика не манипулирует состоянием этих объектов напрямую и не делает никаких проверок которые касаются состояния этих объектов. Программный модуль который реализует эту логику слушает доменные события, которые эти объекты публикуют и принимает решение какие команды на изменение состояния этим объектам выслать. Реализация такого программного модуля тоже может содержать какое-то состояние, как минимум чтобы понимать на каком этапе находится бизнес процесс (паттерн орекстрация). Но может никакого состояния и не иметь (паттерн хореография). Именно так выглядит архитектура больших систем со сложной бизнес логикой.
Да, всё так. И я про то же толкую :)
Логика обработки внутреннего состояния - внутри объекта. Логика составного бизнес-процесса (и то, что выходит за рамки внутреннего состояния объекта) - снаружи. Вариантов (архитектур) реализации - множество.
Касательно примера из статьи, для меня лично, ключевой вопрос - является ли требование уникальности email естественным (неотъемлемым) правилом объекта user. Если да - проверка должна быть встроена в операцию доменного объекта (техники, опять же, множество). В иных случаях эту проверку должен делать тот, кого она интересует.
Но в достаточно большой системе (достаточной для того, чтобы проектировать её согласно DDD) достаточно часто так не будет получаться (достаточно для того, чтобы изначально делать такую архитектуру, где бизнес-логика реализуется вне доменных объектов)
Возможно я вас не правильно понял, но с моей токи зрения логика типа, "если сумма кредита превышает 1000 долларов и кредитная нагрузка клиента превышает 80% то заявка на кредит должна быть отклонена", это не что иное как бизнес логика, и она должна быть реализована внутри доменного объекта Кредитная Заявка.
А если проверка на " кредитная нагрузка клиента превышает 80%" относится к категории рисковых оценок, используется во многих процессах, то где она должна быть? Будете дублировать в каждом объекте?
Вы имеете в виду само значение 80% процентов? Если оно дублируется в нескольких проверках то вынесу его в константу, или в какой то конфиг, и назову maxAcceptableLoanBurden.
Я про саму проверку. Тут и лимит ни фига не константа, а имеет куча зависимостей, да и суть не процентах, а прохождении бизнес-проверок, о сути которых заявка на кредит может и не знать. Но что более интересно, что проверка уровня риска для кредитных продуктов - отдельный процесс, работающий во многих продуктах. И вот, внезапно, на равном месте, где-то в вашей доменной модели появляется сущность "оценка кредитного риска", которая живёт по своим правилам, может обрабатываться как автоматически, так и с привлечением риск-менеджера, имеет кучу зависимостей (кредитная история, куча сведений о клиента, базы антифрода, рисковые модели и пр). Ну вот это точно не внутренность объекта "заявка на кредит".
Ок, в заявку добавляется идентификатор продукта, заявка создается, публикует событие что она создана, на это событие триггерится создание доменного объекта "оценка кредитного риска", состояние которого изменяется (и контролируется самим объектом) в ходе проверки, после его завершения публикуется событие, проверка пройдена или не пройдена, это событие слушает заявка и подтверждается либо отклоняется. Все логика самих проверок по прежнему находится внутри доменных объектов.
Всё так. Но вы сами пишете, что вся логика " находится внутри доменных объектов.". И я вам именно про это говорю - ну не помещается логика бизнес-процесса в одном объекте.
А уж как они будут взаимодействовать - дело другое.
А где я писал что вся бизнес логика должна находиться в одном объекте? Я лишь говорил что она должна находиться внутри объекта оспаривая ваши тезисы:
чтобы изначально делать такую архитектуру, где бизнес-логика реализуется вне доменных объектов)
Объекты доменной модели вообще ничего не знали про логику,
И тут многие идут "упрощением", втаскивая бизнес-логику внутрь объекта
И во множестве реальных ситуация рано или поздно становится понятно, что бизнес-правила - это одном, а низкоуровневая программная модель - это другое.
А я в описанном примере вижу другую проблему. В приложении есть слой для объектов с состоянием, но нет слоя для бизнес-логики. Т.е. проблема возникла тогда, когда бизнес-логику втащили с объект доменной модели.
Если логика составного бизнес процесса реализуется совместной работой нескольких доменных объектов - нельзя говорить, что она содержится в них. Кроме одного случая - у вас домене есть объект, олицетворяющий бизнес-логику. Но, на мой вкус, такие объекты не сильно далеки от сервисов бизнес-логики. Хотя нет, многие делают иначе - оставляют в объектах домена куски несвойственной им логики, а потом (порой болезненно) ищут решения проблем по типу той, которая описана в статье.
Да, в объектах домена содержится их индивидуальная логика. Но логика бизнес-процесса должна быть в том, что с нею связано. Вот та же заявка на кредит - она же, по сути, документ. То, что клиент делает в онлайн-банке аналогично тому, что он придёт в офис и заполнит бланк заявки. Потом это заявку рассматривают, ставят на ней визы и много чего ещё делают другие люди. И есть заявка, а есть бизнес-процесс одобрения заявки. И это точно не один доменный объект.
Вы на примерах показываете, что так оно есть, но на словах согласится не хотите.
Вы пытаетесь усложнить задачу, добавляя дополнительные шаги и усложняя алгоритм, но это не изменяет факта, что разбив это алгоритм на серию команд, обработку бизнес логики каждой из этих команд можно поместить внутрь метода соответствующего доменного объекта. Снаружи будет только оркестратор, который знает какую команду в ответ на какое событие запустить, но ничего не знает о внутренних алгоритмах этих команд.
Оплата заказа в объекте "Оплата"
Объект "оплата" - это что-то вроде чека. Ну или счёт-фактура. Т.е. тоже документ, либо намерение, либо факт. Но кто-то должен выполнять активную функцию, вносить изменение в общее состояние системы (принимать деньги, вносить их на счёт, изменять состояние заказа на "оплачен" и "доставлен", а курьерскую заявку переводить в статус "выполнена". В некоторых нотациях бизнес-процессов этого кого-то называют Actor. И даже если это User, это не та же сущность, которую мы извлекаем из БД по запросу GET api/users/{id}. А в сложно-сочинённых сценариях Actor - это модуль системы.
Т.е получается чтобы реализовать сценарий доставки заказа нам нужно два модуля: сервис курьера и сервис получателя заказа? И сервис курьера должен передать заказ сервису получателя заказа, ведь именно так все происходит а реальном мире, не так ли? Есть два актора один из которых передает товар а второй получает.
Скорее нужны менеджер доставки и агент курьерской службы. Первый по выбору покупателя формирует в заказ на доставку в той или иной курьерской компании, а второй принимает заказ в работу и исполняет его (или не исполняет). По итогу исполнения агент курьерской службы сообщает менеджеру доставки результат (успех/неуспех), а тот выполняет необходимые действия с покупкой.
В реальном магазине будут ещё складская служба, платёжный шлюз и много кто ещё.
Когда ни туда, ни сюда, или в поисках оптимальной границы Domain слоя