Немного про DDD: Реализация событий предметной области в .NET

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        return new CreateOrderResponse();
    }
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    // Добавление набора событий
    public void AddEventRange(IEnumerable 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 OrderItems { get; init; }
    public string ClientPhoneNumber { get; init; }
    public decimal TotalPrice { get; init; }
}

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

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

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

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

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

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

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

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

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

public class OrderService 
{
    private readonly IDomainEventPublisher _publisher;

    public OrderService(IDomainEventPublisher publisher) { /* Внедряем издателя */ }
  
    public async Task 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 Items { get; set; }
    public DateTime CreatedAt { get; set; }

    public static (Order order, IDomainEvent @event) CreateNewOrder(ICollection 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 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 Items { get; set; }
    public DateTime CreatedAt { get; set; }

    // Теперь мы возвращаем экземпляр нашей сущности без события,
    // а также принимаем обязательный аргумент - издателя событий
    public static Order CreateNewOrder(ICollection 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 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 Events { get; }
    public void ClearEvents();
}

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

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

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

    public static Order CreateNewOrder(ICollection 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 options,
        IDomainEventPublisher eventPublisher) : base(options)
    {
        _eventPublisher = eventPublisher;
    }

    public DbSet Orders { get; set; }

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

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

        return base.SaveChangesAsync(cancellationToken);
    }
}

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

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

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

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

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

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

        return new CreateOrderResponse();
    }
}

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

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

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

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

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

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

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

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

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

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

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

© Habrahabr.ru