Когда ни туда, ни сюда, или в поисках оптимальной границы Domain слоя

Если вы не знаете, что такое DDD, то вот отличная статья. Хотя я моментами раскрываю суть некоторых вещей, но моя статья не об этом. Она скорее относится к теме проблем, с которыми сталкивается команда разработчиков при разработке проекта в стиле DDD, да и не только в этом стиле. Проблема, которую я описываю, связана с противоречивостью четким образом на практике отделить одно от другого, ничего не потеряв. В профессиональном сообществе DDD эта проблема называется трилеммой.

Как с наименьшими потерями добиться слабой связности между элементами системы для поддержания их целостности? А конкретно — между слоем бизнес-логики и приложением?

Начнем с того, что слой Application — это не только про оркестрацию, но еще немного про бизнес-логику. Следует это простить и принять внутри себя. А иначе попытки продвинуться дальше в написании кода съедят программиста-перфекциониста живьем.

Можно долго искать решения, читать различные комментарии и книги про разделение бизнес-логики от приложения. И все равно ваша конкретная ситуация будет казаться вам уникальной, как будто ничего нельзя сделать либо надо снова переписывать Domain слой, дабы ни одно зернышко бизнес-логики не выпало за его пределы. А можно просто закрыть глаза на проблемные мечта, забыть об идеале и спать спокойно, рассчитывая, что все чудесным образом само разрулится, если что-то пойдет не так.

7b1f270d94875c3c8ed72a6a9d3df6a7.jpg

Тем не менее, можно сделать кое-что еще. И сейчас я попробую прояснить некоторые моменты, касающиеся оптимальности, которую можно достичь.

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

Что ж, представим типичную ситуацию. Вот сущность User:

public class User
{
    public Guid Id { get; private set; }
    public string Name { get; private set; } = string.Empty;
    public string Password { get; private set; } = string.Empty;
    public string Email { get; private set; } = string.Empty;

    public User(string name, string password, string email)
    {
        Id = Guid.NewGuid();
        Name = name ?? throw new ArgumentNullException(nameof(name));
        Password = password ?? throw new ArgumentNullException(nameof(password));

        ValidateEmail(email);

        Email = email;
    }

    private void ValidateEmail(string email)
    {
        if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
            throw new BusinessException.EmailIsNotValid();
    }
}

public static class BusinessException
{
    public class EmailIsNotValid : Exception { }
}

Обычно бизнес требует много чего от этой сущности, но сейчас нас интересует лишь одно стандартное требование:

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

Логично, понятно, классика.

Теперь вопрос: как это можно проверить в рамках доменного слоя? Ведь это явно не про оркестрацию и менеджмент слоя Application, а конкретное бизнесовое требование.

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

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

Если любите абстракции, то можно выразиться так, что домен — это КАК (как работает и исполняется) и ПРО ЧТО (про логику бизнес процессов), а приложение — это ЧТО (что дергаем и в какой последовательности) и ЗАЧЕМ (какой результат хотим достичь). В теории границы довольно четкие.

Понимая это, вернемся к нашей проблеме: где будем валидировать пресловутый email на уникальность? Бизнесу это необходимо, без этого у него, например, пострадает цельность CRM. Надо что-то решать.

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

Вариант 1: «А давайте сущности передадим массив всех других уникальных сущностей из базы данных, ибо класть мы хотели на перфоманс!»

public User(ICollection users, string name, string password, string email)
{

    foreach (var user in users)
    {
        if (user.Email == email)
            throw new BusinessException.EmailIsNotUnique();
    }
  
    ... 
}

Решение не самое дурацкое, если вопросы эффективности ваc не беспокоят или вы точно знаете, что ими можно пренебречь. Но я бы назвал этот вариант костыльно-программистским. То есть не совсем про домен, поскольку реальную жизнь он не отражает. Вы же, надеюсь, когда регистрируетесь на сайте, сами не проверяете других пользователей, есть ли у них почта, как у вас? Но подход хорош тем, что вся валидация понятным образом захлопывается в самой модели. Модель становится полной.

Вариант 2: «А давайте в параметры сунем не массив, а репозиторий, и дело с концом!»

public User(IUsersRepositorySync usersRepository, string name, string password, string email)
{
    var user = usersRepository.GetByEmail(email);

    if (user != null)
    {
        throw new BusinessException.EmailIsNotUnique();
    }
    ...
}

Казалось бы, то же самое. Но относительно эффективности может быть приятность.

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

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

Предположим, мы смирились с этим. Но почему сущность оперирует понятиями репозиторий или база данных? С ее точки зрения это странно. То есть я регистрируюсь на сайте и как-то зависим от какого-то там репозитория?

«Может, и так, но погодите, парни, ведь пользователю просто надо зарегать у нас аккаунт, чтобы посмотреть расценки!».

В общем и целом, решение нормально. Но надо быть бдительным. Каждая вторая зависимость — это почти что черный ящик. И какая-то часть бизнес логики теперь контролируется черной материей.

Вариант 3: «Короче, просто провалидируем это в самом юзкейсе приложения перед созданием пользователя»

public class HandleUserRegistration(IUsersRepository usersRepository)
    : IRequestHandler
{
    public async Task Handle(
        UserRegistrationCommand request,
        CancellationToken cancellationToken
    )
     {
        var user = await usersRepository.GetByEmail(email);

        if (user != null)
        {
            throw new BusinessException.EmailIsNotUnique();
        }

         var user = new User(request.Name, request.Password, request.Email);

         await usersRepository.Create(user);
          
         return UserDto.FromEntity(user);
     }
}

И я вас поздравляю! Ваше бизнес требование перетекло на уровень оркестрирующего приложение. Вроде ничего плохого. В конце концов, это несколько строк кода. Что теперь, парится из-за них?

Ну, скажу так, если парится неохота, то и забейте. Скорее всего, работать будет. Просто теперь правило, которое должно выполняться всегда и везде, вне зависимости от состояния приложения выполняться может время от времени. А у нас этих приложений в той же папке Application, кстати, может быть несколько, например, админка, а также мобильная версия, и каждое со своей логикой и даже бизнес требованиями. Надо теперь следить за этим, как и за тем, чтобы оставаться dry.

То есть то, что должно находится в одном месте и составлять некую цельность для поддержания контроля и удобства, теперь находится за его пределами, по сути, непонятно где. Напомню: слой Domain не знает ничего о других слоях. Даже репозиторий, которым мы оперировали в предыдущем варианте, лежит в самом домене, он не приходит откуда-то извне. Иными словами, можно создать папку Domain в проекте, наполнить ее всем необходимым, а потом перенести куда-нибудь, и ничего не потеряется. На основе него пилить разные приложения, поскольку вся логика нам известна. Иными словами, все, в чем нуждается доменный слой, лежит в нем самом.

А теперь получается так, что домен на словах должен нуждаться в гарантии уникальностей всех email, но на деле не нуждается в этом, так как это требование просто в нем не реализовано. Выбирать, конечно, вам. У меня же будет бессонница.

Вариант 4: «На это есть доменный сервис!»

Вот это уже кое-что. Доменный сервис — это такая же служба, как и репозиторий, но с конкретной реализацией. (Не забываем: репозитории, которые лежат в домене, являются лишь интерфейсами.) Этот сервис не имеет состояния. Он не хранит в себе значения. В нем нет уникальности. Его цель — служить верой и правдой для бизнеса, выполняя какие-то действия. И… он может запросто зависеть от других доменных служб! Логика сразу становится стройной и контроль над бизнес требованиями сохраняется.

Вот, пожалуйста:

public class CheckUniqueEmailService(IUsersRepositorySync usersRepository)
{
    public void Check(string email)
    {
        var user = usersRepository.GetByEmail(email);

        if (user != null)
        {
            throw new BusinessException.EmailIsNotUnique();
        }
    }
}

Теперь Application слой просто оркестрирует этим.

 public class HandleUserRegistration(IUsersRepository usersRepository, CheckUniqueEmailService checkUniqueEmailService)
     : IRequestHandler
 {
     public async Task Handle(
         UserRegistrationCommand request,
         CancellationToken cancellationToken
     )
     {
         checkUniqueEmailService.Check(request.Email);

         var user = new User(request.Name, request.Password, request.Email);

         await usersRepository.Create(user);
          
         return UserDto.FromEntity(user);
     }
 }

А еще в эту службу мы сможем добавить связанные требования по необходимости, и все в рамках Domain слоя.

Но можно придраться!

Во-первых, подходит к вам дотошный чувак из бизнеса и говорит:

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

И тут вы ему просто с ноги, кааак!…))

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

Вариант 5: «Что вы слышали про доменные события?»

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

Смотрите, наш User создается.

public User(IUsersRepository userRepository, string name, string password, string email)
{
    Id = Guid.NewGuid();
    Name = name ?? throw new ArgumentNullException(nameof(name));
    Password = password ?? throw new ArgumentNullException(nameof(password));

    ValidateEmail(email);

    Email = email;

    AddDomainEvent(new UserWasCreated(Id, Email)); // детали реализации скрыты
}

Никакой валидации на уникальность почты нет. Зато есть событие, которое уже произошло. Причем мы можем даже сделать так на уровне приложения:

public class HandleUserRegistration(IUsersRepository usersRepository)
    : IRequestHandler
{
    public async Task Handle(
        UserRegistrationCommand request,
        CancellationToken cancellationToken
    )
    {
        var user = new User(request.Name, request.Password, request.Email);

        await usersRepository.Create(user);

        return UserDto.FromEntity(user);
    }
}

Юзкейс полностью отработал. Мы добавили пользователя в базу данных.

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

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

Реализуем наш обработчик, который всегда будет в курсе, что событие возникло и будет действовать незамедлительно.

public class ValidateEmailAfterUserCreation
    : IValidateEmailAfterUserCreation,
        INotificationHandler
{
    private readonly IUsersRepository _userRepository;

    public ValidateEmailAfterUserCreation(IUsersRepository userRepository)
    {
        _userRepository =
            userRepository ?? throw new ArgumentNullException(nameof(userRepository));
    }

    public async Task Handle(
        UserWasCreated domainEvent,
        CancellationToken cancellationToken
    )
    {
        var user = await _userRepository.GetByEmail(domainEvent.Email);


        if (user != null)
        {
            // await _userRepository.Remove(user); 
            // если в рамках одной транзакции, то можно просто выбрасывать исключение
            throw new BusinessException.EmailIsNotUnique();
        }
    }
}

Данный обработчик лежит в Application. Но реализуется он с помощью интерфейса, который лежит в Domain. Это удобно и практично по многим причинам. Например, для тестирования. Или мы захотим использовать другую реализацию обработчика, не теряя предыдущую. Или в рамках нескольких приложений, будем пользоваться одним и тем же интерфейсом обработчика, но реализованным по-разному. Интерфейсы, в принципе, штука классная. Грамотно внедрив интерфейс в свой проект даже без других традиционных практик проектирования, мы сделаем код в разы поддерживаемым, хотя неопытным или просто ленивым это зачастую кажется излишним. А ведь интерфейсы придумали ленивые!))

Кажется, написать несколько строк в самом юзкейсе регистрации перед созданием пользователя было более оправданным решением.

«Опять же, на кой, простите, х. так париться? Тем более, везде зависим от репозитория!».

Давайте по порядку:

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

  2. Внедрение зависимостей происходит только в Application слое, что корректно согласно теории. По сути, это одна из задач оркестрации.

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

  4. Наши сущности чисты и невинны. Отвечают только за то, что могут и хотят. Если у человека не может быть третьей руки, то откуда она может появится у нашей сущности?

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

  6. Нет проблем с перфомансом.

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

Попробуйте усложнить пример. Добавьте зависимости, какой-нибудь связанный инвариант наряду с проверкой на null, еще метод к сущности пользователя, который обрабатывает этот инвариант, и вы увидите, как легко поддерживать такой код. Вы четко знаете, что делать и где делать. Весь домен перед нами как на ладони. Приложению четко видны все требования. И все это в рамках одного общего контекста между бизнесом и командой разработчиков. и хотя событийный подход имеет ряд недостатков, но его можно развивать дальше и прийти к outbox паттерну.

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

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

© Habrahabr.ru