Всем привет! Предметно-ориентированное проектирование, на мой взгляд, является недопонятым подходом, о котором многие говорят, но немногие его действительно применяют.

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

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

Скрытый текст

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

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

Что такое события предметной области?

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

События предметной области (или Domain Events) по существу представляют собой объекты, хранящие данные о произошедших действиях и, конечно же, событиях в системе, о которых следует знать нам или уведомить третьих лиц. Эти объекты, после своего создания и публикации (что мы рассмотрим далее), передаются специальным обработчикам, которые выполняют ряд действий, связанных с этими событиями.

Сам по себе данный паттерн предоставляет следующие преимущества:

  • Разделение ответственности - мы отделяем нашу основную бизнес-логику от остальных периферийных и инфраструктурных операций, таких как отправка уведомлений;

  • Изоляция ошибок - если по какой-то причине при обработке события, возникла ошибка, то это не повлияет на основную бизнес-логику;

  • Расширяемость - как мы увидим далее, данный подход также помогает нам легко добавлять новую функциональность при возникновении событий, не затрагивая при этом основную ветку выполнения;

  • Отслеживание и логирование - в некоторых ситуациях данные события можно сохранять в базе данных для дальнейшего анализа.

Способы реализации

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

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

Диаграмма сущностей в системе
Диаграмма сущностей в системе

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

  • Отправить уведомление складскому сервису для того, чтобы он обновил список доступных товаров;

  • Отправить СМС, email, push-уведомление покупателю о том, что его заказ находится в обработке.

В данный момент это всё происходит в методе сервиса:

public class OrderService 
{
    public async Task<CreateOrderResponse> CreateOrder(CreateOrderDto request)
    {
        // Создаем заказ
        var newOrder = new Order() { /* ... */ };

        // Проверка доступности товара на складе
        // Сохранение заказа в БД
        // Отправка уведомления на склад о новом заказе
        // Уведомление клиента о том, что его заказ в обработке

        return new CreateOrderResponse();
    }
}

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

Подготовка. Создания издателя и обработчиков событий

Сейчас вы, возможно, думаете: "А как происходит публикация этих событий?". Для этого мы для начала рассмотрим реализации издателя и обработчиков, которые мы будем применять во всех дальнейших способах.

Скрытый текст

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

Для начала определим требуемые интерфейсы, а именно: интерфейс самих событий, издателя и обработчиков:

// Интерфейс для всех наших событий. Наследуется от типа из MediatR
public interface IDomainEvent : INotification
{
    public DateTime Timestamp { get; }
}

// Интерфейс обработчика событий. Также наследуется от типа из MediatR
public interface IDomainEventHandler<in T> : INotificationHandler<T>
  where T : IDomainEvent;

// Интерфейс издателя событий
public interface IDomainEventPublisher
{
    IReadOnlyCollection<IDomainEvent> Events { get; }

    void AddEvent(IDomainEvent @event);
    void AddEventRange(IEnumerable<IDomainEvent> events);
    Task HandleEvents();
}

Теперь реализуем наш интерфейс издателя для хранения и обработки событий предметной области:

public class DomainEventPublisher : IDomainEventPublisher
{
    private readonly IMediator _mediator;
    private readonly List<IDomainEvent> _events = [];

    public DomainEventPublisher(IMediator mediator)
    {
        _mediator = mediator;
    }

    // Коллекция событий, возникших за время выполнения операции
    public IReadOnlyCollection<IDomainEvent> Events => _events.AsReadOnly();

    // Добавление нового события
    public void AddEvent(IDomainEvent @event) => _events.Add(@event);

    // Добавление набора событий
    public void AddEventRange(IEnumerable<IDomainEvent> events) => _events.AddRange(events);

    // Обработка всех событий через публикацию в MediatR.
    // MediatR вызывает все существующие обработчики 
    // для конкретного типа события
    public async Task HandleEvents()
    {
        foreach (var @event in _events)
        {
            await _mediator.Publish(@event);
        }
    }
}

Также определим необходимый набор данных для нашего события, а также несколько обработчиков для него:

// Определение нашего события. Содержит всю необходимую информацию
public class OrderCreatedEvent : IDomainEvent
{
    public DateTime Timestamp { get; init; } = DateTime.Now;
    public Guid OrderId { get; init; }
    public ICollection<OrderItem> OrderItems { get; init; }
    public string ClientPhoneNumber { get; init; }
    public decimal TotalPrice { get; init; }
}

public class OrderCreatedEventHandlerSms : IDomainEventHandler<OrderCreatedEvent>
{
    public Task Handle(OrderCreatedEvent notification, CancellationToken cancellationToken)
    {
        // Отправляем СМС 
    }
}

public class OrderCreatedEventHandlerWarehouse : IDomainEventHandler<OrderCreatedEvent>
{
    public Task Handle(OrderCreatedEvent notification, CancellationToken cancellationToken)
    {
        // Отправляем сообщение на склад
    }
}

Далее, нам понадобиться зарегистрировать новые зависимости в нашем проекте:

// Файл Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IDomainEventPublisher, DomainEventPublisher>();
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
});

Теперь мы полностью готовы, приступим к реализации самих событий!

Способ 1. Публикация напрямую из сервиса

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

Для этого переработаем рассмотренный выше метод OrderService:

public class OrderService 
{
    private readonly IDomainEventPublisher _publisher;

    public OrderService(IDomainEventPublisher publisher) { /* Внедряем издателя */ }
  
    public async Task<CreateOrderResponse> CreateOrder(CreateOrderDto request)
    {
        // Создаем заказ
        var newOrder = new Order() { /* ... */ };

        // Проверяем доступность товара на складе
        // Сохраняем заказ в БД

        var @event = new OrderCreatedEvent
        {
          Timestamp = newOrder.CreatedAt,
          OrderId = newOrder.Id,
          OrderItems = newOrder.Items,
          ClientPhoneNumber = request.PhoneNumber,
          TotalPrice = /* Расчитываем сумму заказа */
        };
        _publisher.AddEvent(@event);

        return new CreateOrderResponse();
    }
}

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

public class RequestResponseLoggingMiddleware
{
	private readonly RequestDelegate _next;
	 private readonly IDomainEventPublisher _publisher;

	public RequestResponseLoggingMiddleware(RequestDelegate next, IDomainEventPublisher publisher)
	{
		_next = next;
        _publisher = publisher;
	}
	
	public async Task Invoke(HttpContext httpContext, ILogGateway logGateway)
	{
		await _next(httpContext);
		await _publisher.HandleEvents();
	}

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

Способ 2. Возврат событий из методов сущностей

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

Следующий способ более "каноничный" в рамках DDD. Здесь мы уже рассматриваем наши сущности не просто как наборы данных, а самые настоящие объекты, имеющие свое собственное поведение.

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

public class Order
{
    public Guid Id { get; set; }
    public ICollection<OrderItem> Items { get; set; }
    public DateTime CreatedAt { get; set; }

    public static (Order order, IDomainEvent @event) CreateNewOrder(ICollection<OrderItem> items, string clientPhone)
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CreatedAt = DateTime.Now,
            Items = items
        };

        var createEvent = new OrderCreatedEvent
          {
              Timestamp = order.CreatedAt,
              OrderId = order.Id,
              OrderItems = order.Items,
              TotalPrice = /* Вычисляем сумму заказа */,
              ClientPhoneNumber = clientPhone
          };
          
          return (order, createEvent);
    }
}

Теперь также изменим метод нашего сервиса:

public class OrderService 
{
    private readonly IDomainEventPublisher _publisher;

    public OrderService(IDomainEventPublisher publisher) { /* Внедряем издателя */ }
  
    public async Task<CreateOrderResponse> CreateOrder(CreateOrderDto request)
    {
        // Проверяем доступность товара на складе

        // Вызываем фабричный метод нашей сущности
        var (newOrder, @event) = Order.CreateNewOrder(request.OrderItems, request.PhoneNumber);

        // Сохраняем заказ в БД

        // Публикуем событие
        _publisher.AddEvent(@event);

        return new CreateOrderResponse();
    }
}

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

Способ 3. Внедрение издателя событий в сущность

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

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

public class Order
{
    public Guid Id { get; set; }
    public ICollection<OrderItem> Items { get; set; }
    public DateTime CreatedAt { get; set; }

    // Теперь мы возвращаем экземпляр нашей сущности без события,
    // а также принимаем обязательный аргумент - издателя событий
    public static Order CreateNewOrder(ICollection<OrderItem> items, string clientPhone, IDomainEventPublisher publisher)
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CreatedAt = DateTime.Now,
            Items = items
        };

        var createEvent = new OrderCreatedEvent
        {
            Timestamp = order.CreatedAt,
            OrderId = order.Id,
            OrderItems = order.Items,
            TotalPrice = /* Вычисляем сумму заказа */,
            ClientPhoneNumber = clientPhone
        };

        // Публикуем событие напрямую в издателя
        publisher.AddEvent(createEvent);

        return order;
    }
}

А также изменим наш сервис:

public class OrderService 
{
    private readonly IDomainEventPublisher _publisher;

    public OrderService(IDomainEventPublisher publisher) { /* Внедряем издателя */ }
  
    public async Task<CreateOrderResponse> CreateOrder(CreateOrderDto request)
    {
        // Проверяем доступность товара на складе

        // Вызываем фабричный метод нашей сущности
        var newOrder = Order.CreateNewOrder(request.OrderItems, request.PhoneNumber, _publisher);

        // Сохраняем заказ в БД

        return new CreateOrderResponse();
    }
}

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

Способ 4. Публикация через трекинг сущностей

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

"Настоящий" DDD - это постоянные компромиссы и если вас устраивает один из приведенных ранее способов, то с данным вариантом можете ознакомиться просто из интереса. Итак, приступим.

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

Нам понадобиться создать общий интерфейс для всех типов, которые могут публиковать события:

// Интерфейс для всех, кто может публиковать события
public interface IDomainEventEmitter
{
    public IReadOnlyCollection<IDomainEvent> Events { get; }
    public void ClearEvents();
}

public class Order : IDomainEventEmitter
{
    public Guid Id { get; set; }
    public ICollection<OrderItem> Items { get; set; }
    public DateTime CreatedAt { get; set; }

    // Создаем свойство только для чтения наших событий
    public IReadOnlyCollection<IDomainEvent> Events => _events.AsReadOnly();

    // Добавляем приватное поле для хранение событий
    private readonly List<IDomainEvent> _events = [];

    public static Order CreateNewOrder(ICollection<OrderItem> items, string clientPhone, IDomainEventPublisher publisher)
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CreatedAt = DateTime.Now,
            Items = items
        };

        var createEvent = new OrderCreatedEvent
        {
            Timestamp = order.CreatedAt,
            OrderId = order.Id,
            OrderItems = order.Items,
            TotalPrice = /* Вычисляем сумму заказа */,
            ClientPhoneNumber = clientPhone
        };

        // Добавляем событие во внутренний список событий 
        _events.Add(createEvent);

        return order;
    }
}

Теперь наша сущность стала сама по себе хранилищем для событий. Но как же нам их оттуда достать? Для этого мы как раз и используем ChangeTracker . Нам потребуется переопределить метод DbContext.SaveChangesAsync() (или DbContext.SaveChanges() , если вы используете синхронные методы):

public class ApplicationDbContext : DbContext
{
    private readonly IDomainEventPublisher _eventPublisher;

    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options,
        IDomainEventPublisher eventPublisher) : base(options)
    {
        _eventPublisher = eventPublisher;
    }

    public DbSet<Order> Orders { get; set; }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = new())
    {
        var entries = ChangeTracker.Entries<IDomainEventEmitter>().ToList();

        foreach (var entity in entries.Select(entry => entry.Entity))
        {
            _eventPublisher.AddEventRange(entity.Events);
            entity.ClearEvents();
        }

        return base.SaveChangesAsync(cancellationToken);
    }
}

Edited: Также спасибо большое одному из пользователей за то, что подсказал про возможность применения ISaveChangesInterceptor . Таким образом, мы можем сделать следующее:

// Создаем перехватчик сохранения
public class DomainEventInterceptor : ISaveChangesInterceptor
{
    private readonly IDomainEventPublisher _eventPublisher;

    public DomainEventInterceptor(IDomainEventPublisher eventPublisher)
    {
        _eventPublisher = eventPublisher;
    }

    public ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is null)
        {
            return ValueTask.FromResult(result);
        }

        // Переносим логику публикации событий в наш перехватчик
        var entries = eventData.Context.ChangeTracker.Entries<IDomainEventEmitter>().ToList();

        foreach (var entity in entries.Select(entry => entry.Entity))
        {
            _eventPublisher.AddEventRange(entity.Events);
            entity.ClearEvents();
        }

        return ValueTask.FromResult(result);
    }
}

public class ApplicationDbContext : DbContext
{
    private readonly IDomainEventPublisher _eventPublisher;

    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options,
        IDomainEventPublisher eventPublisher) : base(options)
    {
        _eventPublisher = eventPublisher;
    }

    public DbSet<Order> Orders { get; set; }

    // Регистрируем наш перехватчик
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.AddInterceptors(new DomainEventInterceptor(_eventPublisher));

        base.OnConfiguring(optionsBuilder);
    }
}

Более подробно об использовании ISaveChangesInterceptor вы можете узнать на этой GitHub странице.

Скрытый текст

Да, в принципе можно и реализовать это без использования EntityFramework, но это занимает очень много сил и не факт, что получится лучше.

При помощи инструментов EntityFramework мы достаем все экземпляры, реализующие интерфейс IDomainEventEmitter , а после публикуем все события этих объектов в нашего издателя. Таким образом, код нашего сервиса будет выглядеть таким образом:

public class OrderService 
{
    public OrderService() { /* Внедряем нужные зависимости */ }
  
    public async Task<CreateOrderResponse> CreateOrder(CreateOrderDto request)
    {
        // Проверяем доступность товара на складе

        // Вызываем фабричный метод нашей сущности
        var newOrder = Order.CreateNewOrder(request.OrderItems, request.PhoneNumber);

        // Сохраняем заказ в БД

        return new CreateOrderResponse();
    }
}

Главное, чтобы по окончанию обработки запроса, был вызван DbContext.SaveChangesAsync() , иначе события не будут опубликованы и обработаны через издателя. Но эту операцию можно также добавить в цепочку обработки запроса в middleware, например.

Таким образом, мы полностью избавили наш сервис от необходимости знать про какие-либо периферийные детали обработки событий.

Заключение и выводы

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

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

Я рассмотрел не все возможные варианты, но как мне кажется, это наиболее простые и понятные. В заключение я хочу подвести итог по всем четырем способам:

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

  • Способ 2 - требует определенного понимания основ DDD. Ваши сущности должны иметь логику, хотя бы на уровне фабричных методов. Также явно принуждает сервис публиковать события, о чем можно забыть;

  • Способ 3 - практически целиком убирает потребность сервиса в участии в публикации событий. Но требует участия инфраструктурных инструментов в работе сущностей, что может некоторых расстроить;

  • Способ 4 - полностью убирает участие сервиса в публикации событий. В то же время, сильно ограничивает в выборе инструментов работы с БД, а также заставляет использовать "богатые" на бизнес-логику сущности в качестве сущностей EF, что порой бывает очень проблемно.

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

Комментарии (16)


  1. EgorovDenis
    29.09.2024 09:50

    А представьте, что к вам приходит бизнес и говорит, что Id у объекта Order должен быть int и определяться уже после запроса в БД, а вам нужно писать событие OrderCreatedEvent с указанием id созданного заказа. Плюс к тому же данные транзакции и событие должны писаться в рамках одной транзакции (outbox) и событие изменения должно публиковаться как интеграционный эвент вместо доменного. Плюс к тому же это интеграционное событие должно быть ответом на другое событие из какого-нибудь CorrelationId и указанием конкретной очереди, куда нужно писать и передачей некоторых параметров из прошлого события (например, того же CorrelationId), но указывать его надо не в тело сообщения, а в headers.

    Вот тогда ваш подход начнет ломаться.


    1. KrawMire Автор
      29.09.2024 09:50

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

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

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

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

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

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

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

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

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

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


  1. tsvettsih
    29.09.2024 09:50

    Выбран неудачный пример для демонстрации доменных ивентов. Уведомления - это типичный пример для Outbox паттерна. Здесь обсуждаются интеграционные событие, а не доменные.


    1. KrawMire Автор
      29.09.2024 09:50

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

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

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


  1. tsvettsih
    29.09.2024 09:50

    А еще хендлер доменного ивента может изменить какой-то агрегат, который тоже захочет отправить об этом ивент. Как решить этот кейс?


    1. KrawMire Автор
      29.09.2024 09:50

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

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


  1. Gromilo
    29.09.2024 09:50
    +1

    "Настоящий" DDD - он как настоящий шотландец. Или как подростковый секс - все о нём говорят, но мало кто практикует.

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

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


    1. KrawMire Автор
      29.09.2024 09:50

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

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

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


  1. michael_v89
    29.09.2024 09:50
    +1

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

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

    OrderItems = newOrder.Items

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

    ClientPhoneNumber = request.PhoneNumber

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

    В рамках нашего примера, мы добавим новый фабричный метод в сущность Order , который будет отвечать за создание нового экземпляра заказа

    То есть заказ будет создавать сам себя? Это не выглядит похожим на домен.
    Если мы переместим статический метод CreateNewOrder в другой класс, то он никак не изменится. А если так, то зачем его надо было туда помещать? Смысл методов класса в том, что они могут работать с его внутренним состоянием, чтобы вызывающему коду не надо было работать с деталями реализации. А статические методы могут работать только со статическими переменными, что фактически формирует отдельный класс. А если нам все равно нужен отдельный класс, то лучше сделать его явно.

    Id = Guid.NewGuid(),
    CreatedAt = DateTime.Now,

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

    return (order, createEvent);

    Вот-вот, надо еще и сложное состояние возвращать. Это говорит о том, что метод находится не там, где он должен быть.

    Мы снова уменьшили ответственность нашего сервиса

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

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

    Что насчет логгера, тоже будете передавать аргументом? А другие зависимости?
    Вот так постепенно ваш метод превращается в сервис.

    public static CreateNewOrder()
    publisher.AddEvent(createEvent);

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

    // Создаем свойство только для чтения наших событий
    public IReadOnlyCollection Events => _events.AsReadOnly();

    О каких чистых сущностях можно говорить, если у нас там есть публично доступные технические свойства. Это же domain-driven design, у вас в domain у этой сущности разве есть свойство Events?

    Теперь наша сущность стала сама по себе хранилищем для событий.

    Что там, говорите, было с ответственностями у сервиса?) Зачем мы его начали переделывать?

    Таким образом, мы полностью избавили наш сервис от необходимости знать про какие-либо периферийные детали обработки событий.

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


    1. KrawMire Автор
      29.09.2024 09:50

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


      1. michael_v89
        29.09.2024 09:50

        Но даже если обращаться к тому же SOLID, то это явное нарушение принципа единой ответственности

        Нет, принцип единой ответственности не запрещает передавать в конструктор 2 зависимости и вызывать из них по 1 методу.

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

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

        Сервис в идеале должен исполнять то, что относиться непосредственно к логике системы.

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

        На самом деле, это достаточно распространенный шаблон для сущностей в DDD

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

        Соглашусь с тем, что вариант с реализацией фабричного метода в родительском классе выглядит лучше

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

        Точно также, про это было сказано в самом материале.

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

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

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

        Обработка событий происходит после окончания выполнения метода.

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

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

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

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

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

        мы идем в сущность
        идем в сервис

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


        1. KrawMire Автор
          29.09.2024 09:50

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


          1. michael_v89
            29.09.2024 09:50

            При работе в команде в итоге это может привести к конфликтам

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

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

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

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

            Реализация бизнес-требования "Если все части задания выполнены, отправить письмо руководителю" это бизнес-логика.
            Как вы устанавливаете значения в this это не бизнес-логика, а детали реализации.

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

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

            И причем здесь про отладку, не совсем понимаю

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

            вы транзакциями при работе с БД тоже управляете в каждом методе отдельно?

            Да, потому что иногда бывает так, что надо сделать 2 транзакции. Сначала сохраняем заказ с одним статусом, потом производим оплату, потом сохраняем с другим. Иногда надо получить данные из другой системы, и держать транзакцию открытой всё действие неправильно. Иногда транзакциями управляет ORM, но там все равно надо вызывать какой-нибудь метод flush() для фактического сохранения.

            Причем здесь Кафка, если речь про внутренние события?

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

            Не понимаю вопроса в этом плане.

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


            1. KrawMire Автор
              29.09.2024 09:50

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

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

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

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

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

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

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

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

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

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

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

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


              1. michael_v89
                29.09.2024 09:50

                Я говорю про соглашения, принятые на проекте.

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

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

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

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

                У меня тут потерялось слово "иногда". Но согласен, так будет работать, я сначала неправильно понял вашу мысль.

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

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


                1. KrawMire Автор
                  29.09.2024 09:50

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

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

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

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

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

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

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

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