Implementing Domain Events in .NET

Hi all! Domain Driven Design, in my opinion, is a misunderstood approach that many people talk about, but few actually use.

It seems to me that the problem most often is that people perceive its use as “all or nothing” – either the project must follow each and every pattern offered by DDD, or the team should not consider it at all. In my opinion, this is not entirely the right approach, since DDD provides a fairly extensive set of techniques and patterns, not all of which will really pay off efforts to create them, while the rest can significantly improve the project architecture, as well as simplify product maintenance in the future.

One of the relatively easy-to-implement and architecturally useful patterns, in my opinion, is Domain Events. In this article I would like to talk about possible options for implementing this DDD pattern.

Hidden text

For those who already have experience with implementing this pattern, I would like to say right away that I will not consider the approach using Event Sourcing, since it requires significant changes in the system architecture, and sometimes a revision of the approach to the development and perception of the system as a whole.

The goal of this article is to show how you can implement domain events into your system without much difficulty, as well as what advantages such an application creates.

What are domain events?

I don't like long intros, so I'll try to quickly dive you into what these domain events essentially are.

Domain Events are essentially objects that store data about actions that have taken place and, of course, events in the system that we should know about or notify third parties about. These objects, once created and published (which we'll look at next), are passed to special handlers that perform a series of actions related to these events.

This pattern itself provides the following advantages:

  • Sharing of Responsibility – we separate our core business logic from other peripheral and infrastructure operations, such as sending notifications;

  • Error isolation – if for some reason an error occurs during event processing, this will not affect the main business logic;

  • Extensibility – as we will see later, this approach also helps us to easily add new functionality when events occur without affecting the main execution branch;

  • Tracking and logging – in some situations, event data can be stored in a database for further analysis.

Implementation methods

Let us finally begin to consider possible implementation options. I will try to consider these methods in order of increasing complexity: first there will be options that do not require much effort, and then based on them we will consider more complex methods, but in my opinion, more elegant.

An example in which we will consider all the methods is quite banal, but very indicative – the fruit sales system. At the moment, the system has product and order entities. There is also an order service that is responsible for creating these orders. The general structure of entities is presented below:

Diagram of entities in the system

Diagram of entities in the system

While considering implementations of domain events, we will be faced with a simple task – to process the event of creating a new order, namely:

  • Send a notification to the warehouse service so that it updates the list of available goods;

  • Send SMS, email, push notification to the buyer that his order is being processed.

Currently this all happens in the service method:

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

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

        return new CreateOrderResponse();
    }
}

As we can see, our service is responsible for literally all actions, which makes it very difficult to make changes and also creates many points of failure. Let's solve this with domain events!

Preparation. Creating the Publisher and Event Handlers

Now you may be thinking: “How do these events get published?” To do this, we will first look at the implementation of the publisher and handlers, which we will use in all further methods.

Hidden text

To implement both publishers and handlers, I decided to use a library familiar to many MediatR, to simplify the implementation of the main essence as much as possible.

First, let's define the required interfaces, namely: the interface of the events themselves, the publisher and the handlers:

// Интерфейс для всех наших событий. Наследуется от типа из 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();
}

Now let's implement our publisher interface for storing and processing domain events:

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);
        }
    }
}

We will also define the necessary data set for our event, as well as several handlers for it:

// Определение нашего события. Содержит всю необходимую информацию
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)
    {
        // Отправляем сообщение на склад
    }
}

Next, we need to register new dependencies in our project:

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

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

Now we are completely ready, let's start implementing the events themselves!

Method 1. Publishing directly from the service

A service is understood as a standard object that does not have its own state, but contains only a set of methods for performing any actions in the system. We can publish our event directly in the service method after performing certain actions.

To do this, we will rework the method discussed above 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();
    }
}

Great, we've published our event, but now we need to call a method DomainEventPublisher.HandleEvents() to handle this event. This can also be done from a service from the same method, but it is best to integrate event processing into the request processing chain, for example, in middleware, since we can make several calls to different services within one controller request:

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();
	}

Thus, after the controller call is fully processed, the middleware will trigger all events to be processed. And as a result, we have reduced the responsibility of our service – it now deals only with business logic, and our handlers are now responsible for all peripheral calls that are not related to the business process.

Method 2: Returning events from entity methods

Despite the positive aspects of the first method, our service still requires the “manual” creation of the event itself, that is, although partially, it is still involved in secondary actions.

The next method is more “canonical” within DDD. Here we already consider our entities not just as sets of data, but as real objects with their own behavior.

For our example, we will add a new factory method to the entity Order which will be responsible for creating a new order instance, as well as creating an event:

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);
    }
}

Now let's also change the method of our service:

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();
    }
}

We have again reduced the responsibility of our service; now it doesn’t even need to know what event it receives – it simply publishes what was given to it after calling the entity. Great, now let's look at the next implementation option.

Method 3: Inject an event publisher into an entity

Although the second method hid some details of event creation in essence, our service still continues to be responsible for the publication of these events. And this can lead to the fact that we may forget that an entity method can return an event to us or that we will need to publish it. This situation will worsen if we are not the only ones working on the project, but also our colleagues.

The next implementation implies that we must also call the entity's factory method, but now we also need to pass the event publisher as an argument to this method. Let's change the definition of the entity method a little 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;
    }
}

We will also change our service:

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();
    }
}

It just gets better every time! Now our service is not involved in any way in the process of publishing events, which means that it bears responsibility only for business logic. We have made significant progress. Now let's look at the last method.

Method 4. Publishing via entity tracking

Despite the fact that we have done a lot of work to remove peripheral logic from the service, we have a new problem – our entities must know about some details of the system infrastructure. For some, this may not seem like a problem at all, and I can partially agree with this. But we will also look at another method that will completely hide from us all the details of working with events, but will add some complexity to the project.

“Real” DDD is about constant compromises, and if you are satisfied with one of the previously mentioned methods, then you can familiarize yourself with this option just out of interest. So let's get started.

For this method we will need to use EntityFrameworkand in particular ChangeTracker. This object tracks changes in entities that were involved in the work DbContext which is exactly what we need.

We will need to create a common interface for all types that can publish events:

// Интерфейс для всех, кто может публиковать события
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;
    }
}

Now our essence has become a repository for events in itself. But how can we get them out of there? For this purpose we use ChangeTracker . We will need to override the method DbContext.SaveChangesAsync() (or DbContext.SaveChanges() if you are using synchronous methods):

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);
    }
}
Hidden text

Yes, in principle, you can implement this without using EntityFramework, but it takes a lot of effort and it’s not a fact that it will turn out better.

We have overridden the behavior of our context so that it retrieves all instances that implement the interface we define IDomainEventEmitter and then publishes all events of these objects to our publisher. Thus, our service code will look like this:

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

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

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

        return new CreateOrderResponse();
    }
}

The main thing is that at the end of processing the request, it should be called DbContext.SaveChangesAsync() otherwise the events will not be published and processed through the publisher. But this operation can also be added to the request processing chain in middleware, for example.

Thus, we have completely relieved our service of the need to know about any peripheral details of event processing.

Conclusion and conclusions

First of all, I want to thank you for reading my article to the end. I have been looking for materials on events in the subject area for a long time and found information bit by bit on the Internet, and this is what I wanted to share so that people studying this issue could immediately find everything in one article.

As a result of considering all the ways to implement the publication of domain events, we were able to create an approach in which we only need to publish the event through the publisher one way or another, and then call a method for processing these events. Thanks to this approach, we can now easily add new handlers to existing events, as well as create new ones, separating business logic and minor communication details.

I have not considered all possible options, but it seems to me that these are the simplest and most understandable. In conclusion, I want to summarize all four methods:

  • Method 1 – easy to implement, does not require any special modifications to the system, and also allows you to adapt to almost any project. But at the same time, we do not completely get rid of the need for the service to know about the details of events. On the other hand, it can improve the understandability of the system, since all events are published explicitly;

  • Method 2 – requires some understanding of DDD basics. Your entities must have logic, at least at the level of factory methods. It also clearly forces the service to publish events, which can be forgotten;

  • Method 3 – almost completely eliminates the need for the service to participate in publishing events. But it requires the participation of infrastructure tools in the operation of entities, which may upset some;

  • Method 4 – completely removes the service’s participation in publishing events. At the same time, it greatly limits the choice of tools for working with the database, and also forces the use of entities “rich” in business logic as EF entities, which can sometimes be very problematic.

I hope this material was useful to you. In the future, I plan to dive into thinking about using the rest of the strategic and tactical Domain Driven Design patterns.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *