Pull to refresh
4
0
Гаврилов Антон @KrawMire

.NET Backend Developer

Send message

Можно встроить фиксацию транзакции после обработки событий. Для этого Unit of Work обычно используется.

Мне кажется, что в таком случае рассмотренные способы вряд ли будут работать как надо. Возможно, в вашем случае подойдет паттерн Publisher-Subscriber? Вы также создаете класс Publisher, но который не хранит события, а просто выполняет их пересылку по подписчикам. А ViewModels могут подписываться на события изменения.

Извиняюсь за большой размер примера, но вот так, например (пример только на C# могу сделать):

// Определения событий
public record Event1(string Name);
public record Event2(int Age);

// Интерфейс обработчика события.
// В generic типе - тип обрабатываемого события
public interface IEventListener<in TEvent>
{
    void HandleEvent(TEvent @event);
}

// Интерфейс издателя событий.
// На него могут подписаться обработчики, а он
// затем вызовет метод обработки событий
public interface IEventPublisher<TEvent>
{
    void Subscribe(IEventListener<TEvent> listener);
    void RaiseEvent(TEvent @event);
}

// Издатель первого события
public class Event1Publisher : IEventPublisher<Event1>
{
    // Коллекция подписчиков
    private readonly ICollection<IEventListener<Event1>> _listeners = [];
    
    public void Subscribe(IEventListener<Event1> listener)
    {
        _listeners.Add(listener);
    }

    public void RaiseEvent(Event1 @event)
    {
        foreach (var listener in _listeners)
        {
            listener.HandleEvent(@event);
        }
    }
}

// Издатель второго события
public class Event2Publisher : IEventPublisher<Event2>
{
    private readonly ICollection<IEventListener<Event2>> _listeners = [];
    
    public void Subscribe(IEventListener<Event2> listener)
    {
        _listeners.Add(listener);
    }

    public void RaiseEvent(Event2 @event)
    {
        foreach (var listener in _listeners)
        {
            listener.HandleEvent(@event);
        }
    }
}

// Первый ViewModel. Тут мы реализуем интерфейсы с
// generic типами тех событий, которые мы будем обрабатывать
public class ViewModel1 : IEventListener<Event1>, IEventListener<Event2>
{
    private readonly IEventPublisher<Event1> _event1Publisher;
    
    public ViewModel1(IEventPublisher<Event1> event1Publisher)
    {
        // Подписываемся на события издателя
        _event1Publisher = event1Publisher;
        _event1Publisher.Subscribe(this);
    }
    
    public void HandleEvent(Event1 @event)
    {
        Console.WriteLine($"HandleEvent {@event.Name}");
    }

    public void HandleEvent(Event2 @event)
    {
        Console.WriteLine($"HandleEvent {@event.Age}");
    }

    public void DoSomeOperation()
    {
        // В процессе выполнения какой-то операции
        // нам нужно изменить данные в остальных связанных ViewModel'ях 
        _event1Publisher.RaiseEvent(new Event1("Alexander"));
    }
}

// Второй ViewModel. Тут мы только слушаем события, но не вызываем их
public class ViewModel2 : IEventListener<Event2>
{
    public ViewModel2(IEventPublisher<Event2> event1Publisher)
    {
        event1Publisher.Subscribe(this);
    }
    
    public void HandleEvent(Event2 @event)
    {
        Console.WriteLine($"ViewModel2 HandleEvent {@event.Age}");
    }
}

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

Если вы под бизнес-логикой понимаете что-то другое, то у вас и будет не DDD, а какой-то свой подход.

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

При этом самому пользователю не должно быть разрешено менять этот статус. Тут нужные проверки зависят от действия.

Да, сущность сама регулирует, что когда она "на модерации", то её поля не могут измениться. Процесс же изменения статуса действительно зависит от инфраструктурных деталей (проверка роли пользователя/модератора). То есть пока мы не поменяем статус с "на модерации" на какой-то другой, то сущность сама не будет давать нам изменять поля. Статус же чаще всего меняться через специальный метод для этого.

Логика в сервисах это вполне нормальное решение.

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

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

Мы просто с вами тут уже фундаментальные вопросы к DDD рассматриваем :)

У бизнес-логики есть общепринятое определение.

Я говорю про соглашения, принятые на проекте. Разные команды могут по-разному понимать какие-либо детали реализации. Я рассматривал в своем примере только логику, относящуюся к внутреннему состоянию системы.

Правила валидации обычно зависят от действия, а не от сущности.

Правила валидации входных DTO - да. Правила валидации сущностей чаще всего одни на всех - это же инвариант самой сущности.

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

Частично можно. Middleware -> Вызов метода сервиса -> Вызов обработки событий.
Но на самом деле, здесь вы правы. Сам факт применения MediatR усложняет этот процесс, так как он вызывает обработчики "под капотом". Но, опять же, решения для всего и вся не существует и часто приходиться идти на компромиссы.

Да, потому что иногда бывает так, что надо сделать 2 транзакции.

Ключевое слово "иногда". Для этого можно сделать специальную обработку. Я говорю в контексте вызова метода издателя на обработку событий - она для всех одинакова.

Потому что нужно отправить данные созданной сущности в другую систему.

Не, я про то, что данные не отправятся, если основная транзакция завершится неудачей. Так что обработчик не вызовется.

DDD говорит про чистые сущности, но не может это обеспечить.

Да, пока что универсального подхода, который был бы хорош во всем, к сожалению, не придумали. Я в статье старался рассказать просто про один интересный подход к разбиению логики, без сильной привязки к DDD, но без него никуда в рамках моего рассуждения :(

Ну так это и правильно, потому что сервис это модель этих процессов. Поменялись процессы - поменялась модель.

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

Последовательность "Сначала сохранить, потом отправить в другую систему" это и есть логика, она обсуждается с бизнесом.

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

Если что-то распространено, это не значит, что это правильно. Я привел конкретные недостатки, они не исчезают от того, что многие так делают.

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

Я не нашел там ничего про это. Вы просто сообщаете, что возвращать сложное состояние это более правильный вариант, чем исходный.

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

Если у вас в бизнес-требованиях написано "Сохранить заказ, потом отправить уведомление", то должен быть код, который выполняет оба действия

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

То есть _publisher.AddEvent(event) ничего никуда не отправляет, а просто имитирует, и у нас где-то есть еще код, который выполняется после логики и делает фактическую отправку?

Об этом написано в статье в "Подготовка. Создания издателя и обработчиков событий", а сам вызов обработки событий в "Способ 1. Публикация напрямую из сервиса". Да, события не выполняются сразу после публикации. И причем здесь про отладку, не совсем понимаю, вы транзакциями при работе с БД тоже управляете в каждом методе отдельно? Обычно, это общая логика, которая помещается, например, в middleware.

Если в каких-то обработчиках происходит фактическая отправка событий в другую систему, то не позволит. Условная Кафка не связана с транзакциями базы.

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

Ну и что, что он распространенный? Несоответствие заявленным целям от этого не исчезает.

Хранение событий в сущности - это также может являться частью модели предметной области. Не понимаю вопроса в этом плане.

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

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

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

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

Если вы передаете в событие сущность OrderItem, то можно и Order так же передавать, необязательно копировать все свойства Order, да еще и с другими именами.

Да, согласен, некоторые части примеров неидеальны, но на них и не ставится акцент. Основная задача - показать способы реализации и использования событий предметной области.

Если запрашивается PhoneNumber, значит он зачем-то понадобится потом, значит он где-то сохраняется - в заказе или в отдельной сущности. Вот ее и надо передавать в событии, как есть или преобразованную в DTO, а не напрямую данные из запроса.

Здесь то же самое - цель в статье стояла другая. Это моя ошибка, согласен, что не продумал до конца рассматриваемую систему. Я ставил в приоритет именно реализацию шаблона.

То есть заказ будет создавать сам себя? Это не выглядит похожим на домен.

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

Что если нам нужен специальный генератор id, передавать технический компонент в аргументах вместе с бизнес-данными?

Я указал на это в самой статье.

Вот-вот, надо еще и сложное состояние возвращать. 

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

Этот сервис и был нужен для содержания этой ответственности.

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

Что насчет логгера, тоже будете передавать аргументом?

Смотря о логировании чего вы говорите. Логирование событий может делать издатель.

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

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

Это же domain-driven design, у вас в domain у этой сущности разве есть свойство Events?

Да, крайне распространенный подход. Microsoft, например, об этом прямо пишет в своей документации.

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

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

Согласен. По сути, так и есть. Основная идея DDD в том, чтобы инкапсулировать модель предметной области в некий модуль, чтобы все инфраструктурные детали разрабатывать независимо от бизнес-логики. И да, вы правы, что польза от такого подхода появляется только в ходе использования в больших проектах, а в мелких - скорее усложнит систему и не окупит затраченных усилий.

Что касается примера - это скорее выдуманный юз-кейс, чтобы можно было показать на чем-то реальном.

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

Можно реализовать набор событий в издателе не в виде коллекции, а в виде очереди, причем потокобезопасную (чтобы исключения не выбрасывались при добавлении новых событий в коллекцию во время перебора в цикле), либо сделать перебор не через foreach, а в цикле while для перебора до конца.

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

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

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

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

А представьте, что к вам приходит бизнес и говорит, что Id у объекта Order должен быть int и определяться уже после запроса в БД, а вам нужно писать событие OrderCreatedEvent с указанием id созданного заказа.

Верное замечание, на самом деле, но поэтому я и привел несколько способ реализации этого шаблона. В данном случае мы можем воспользоваться 1 способом с прямой публикацией из сервиса, то есть мы сначала сохраняем заказ в БД, получая его Id (типа int), затем публикуем событие о создании во внутреннем издателе.

Плюс к тому же данные транзакции и событие должны писаться в рамках одной транзакции (outbox)

Мы можем вызывать метод HandleEvents() в рамках одной транзакции с основной бизнес-логикой - это больше зависит от требований, но проблем никаких нет.

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

Можно создать обработчик этого события, который будет отправлять это событие куда угодно: сохранять в базе, класть в очередь сообщений для других сервисов, либо отправлять в синхронном стиле по HTTP/REST или gRPC - здесь на ваше усмотрение. Честно говоря, не совсем понял смысл фразы "интеграционный эвент вместо доменного", мы можем создавать интеграционное событие в ходе обработки доменного.

Плюс к тому же это интеграционное событие должно быть ответом на другое событие из какого-нибудь CorrelationId и указанием конкретной очереди.

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

передачей некоторых параметров из прошлого события (например, того же CorrelationId), но указывать его надо не в тело сообщения, а в headers.

Повторюсь, но в обработках событий мы можете делать вообще что хотите, хоть просто в консоль писать информацию о событии. Собственно, вы точно также можете писать в headers требуемый CorrelationId.

Насколько я помню, YandexCloud ObjectStorage использует API от AWS S3. Из-за этого с ним можно (или нужно, тут могу ошибаться) взаимодействовать через AWS CLI (Ссылка на доку YC: https://yandex.cloud/en/docs/storage/tools/aws-cli).

Плюс, сам Яндекс редко делает какие-либо библиотеки для работы со своими продуктами для .NET (ClickHouse, например), из-за чего для работы с ObjectStorage самый удобный вариант - это использование клиентской библиотеки для AWS S3.

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

Например, таким образом удобно управлять транзакцией вместо того, чтобы открывать и закрывать её отдельно в каждом UseCase. Также, таким образом, после выполнения операции можно вызвать обработку всех событий предметной области, произошедших в процессе выполнения операции. А еще можно добавить туда мониторинг и логирование (если по каким-то причинам вы не используете Middleware, конечно), либо просто замерять скорость выполнения UseCase.

Спасибо за комментарий!

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

Но я также рассматриваю спланированную архитектуру больше не с точки зрения разового преимущества, а скорее заложения определенного фундамента под будущие изменения. Кто знает, может быть, проект действительно заинтересует кого-то и потребуется его дальнейшая доработка с добавлением возможностей, отличных от простых публикаций/чтения статей :)

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

Да, главное - находить некую "золотую середину" между сложными в реализации и понимании подходами и сведению всей системы к "контроллер-репозиторий".

Согласен, Роберт Мартин также использует эти термины. Я скорее имею ввиду общепризнанное именование таких компонентов. Согласитесь, гораздо реже для реализации логики приложения используется понятие UseCase, нежели чем Service / ApplicationService

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

Фактически, получается, что операции выполняются UseCase'ами, а ApplicationService является скорее неким фасадом, который объединяет их, а также выполняет управление транзакцией в процессе выполнения UseCase'a

Information

Rating
Does not participate
Location
Москва, Москва и Московская обл., Россия
Date of birth
Registered
Activity