How To Use Domain Events To Build Loosely Coupled Systems

Milan Jovanović - Oct 14 '23 - - Dev Community

In software engineering, "coupling" means how much different parts of a software system depend on each other. If they are tightly coupled, changes to one part can affect many others. But if they are loosely coupled, changes to one part won't cause big problems in the rest of the system.

Domain events are a Domain-Driven Design (DDD) tactical pattern that we can use to build loosely coupled systems.

You can raise a domain event from the domain, which represents a fact that has occurred. And other components in the system can subscribe to this event and handle it accordingly.

Here's what you will learn in this week's newsletter:

  • What are domain events
  • How they're different from integration events
  • How to implement & raise domain events
  • How to publish domain events with EF Core
  • How to handle domain events with MediatR

We have a lot to cover, so let's dive in.

What Are Domain Events?

An event is something that has happened in the past.

It is a fact.

Unchangeable.

A domain event is something that happened in the domain, and other parts of the domain should be aware of it.

Domain events allow you to express side effects explicitly, and provide a better separation of concerns in the domain. They're an ideal way to trigger side effects across multiple aggregates inside the domain.

It's your responsibility to ensure that publishing a domain event is transactional. You'll see why this is easier said than done.

Image description

Domain Events Versus Integration Events

You may have heard of integration events, and you're now wondering what's the difference between them and domain events.

Semantically, they're the same thing: a representation of something that occurred in the past.

However, their intent is different and this is important to understand.

Domain events:

  • Published and consumed within a single domain
  • Sent using an in-memory message bus
  • Can be processed synchronously or asynchronously

Integration events:

  • Consumed by other subsystems (microservices, Bounded Contexts)
  • Sent with a message broker over a queue
  • Processed completely asynchronously

So if you're wondering what type of event you should publish, think about the intent and who should be handling the event.

Domain events can also be used to generate integration events, which leave the domain boundary.

Implementing Domain Events

My preferred approach to implement domain events is creating an IDomainEvent abstraction and implementing MediatR INotification.

The benefit is you can use MediatR's publish-subscribe support to publish a notification to one or multiple handlers.

using MediatR;

public interface IDomainEvent : INotification
{
}
Enter fullscreen mode Exit fullscreen mode

Now you can implement a concrete domain event.

Here are a few constraints to consider when designing domain events :

  • Immutability - domain events are facts, and should be immutable
  • Fat vs Thin domain events - how much information do you need?
  • Use past tense for event naming
public class CourseCompletedDomainEvent : IDomainEvent
{
    public Guid CourseId { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

Raising Domain Events

After you create your domain events, you want to raise them from the domain.

My approach is creating an Entity base class, because only entities are allowed to raise domain events. You can further encapsulate raising domain events by making the RaiseDomainEvent method protected.

We're storing domain events in an internal collection, to prevent anyone else from accessing it. The GetDomainEvents method is there to get a snapshot of the collection, and the ClearDomainEvents method to clear the internal collection.

public abstract class Entity : IEntity
{
    private readonly List<IDomainEvent> _domainEvents = new();

    public IReadOnlyList<IDomainEvent> GetDomainEvents()
    {
        return _domainEvents.ToList();
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }

    protected void RaiseDomainEvent(IDomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now you're entities can inherit from the Entity base class and raise domain events:

public class Course : Entity
{
    public Guid Id { get; private set; }

    public CourseStatus Status { get; private set; }

    public DateTime? CompletedOnUtc { get; private set; }

    public void Complete()
    {
        Status = CourseStatus.Completed;
        CompletedOnUtc = DateTime.UtcNow;

        RaiseDomainEvent(new CourseCompletedDomainEvent { CourseId = this.Id });
    }
}
Enter fullscreen mode Exit fullscreen mode

And all that's left to do is publish the domain events.

How To Publish Domain Events With EF Core

An elegant solution for publishing domain events is using EF Core.

Since EF Core acts as a Unit of Work, you can use it to gather all domain events in the current transaction and publish them.

I don't like to complicate things, and simply override the SaveChangesAsync method to publish the domain events after persisting the changes in the database. But you could also use an interceptor.

public class ApplicationDbContext : DbContext
{
    public override async Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default)
    {
        // When should you publish domain events?
        //
        // 1. BEFORE calling SaveChangesAsync
        // - domain events are part of the same transaction
        // - immediate consistency
        // 2. AFTER calling SaveChangesAsync
        // - domain events are a separate transaction
        // - eventual consistency
        // - handlers can fail

        var result = await base.SaveChangesAsync(cancellationToken);

        await PublishDomainEventsAsync();

        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

The most important decision you will have to make here is when to publish the domain events.

I think it makes the most sense to publish after calling SaveChangesAsync. In other words, after saving changes to the database.

This comes with a few tradeoffs:

  • Eventual consistency - because messages are processed after the original transactions
  • Database inconsistency risk - because handling domain events can fail

Eventual consistency is something I can live with, so I choose to make this tradeoff.

However, introducing a risk of database inconsistency is a big concern.

You can solve this with the Outbox pattern,where you persist your changes to the database and the domain events (as outbox messages) in a single transaction. Now you have a guaranteed atomic transaction, and the domain events are processed asynchronously using a background job.

If you're wondering what's inside the PublishDomainEventsAsync method:

private async Task PublishDomainEventsAsync()
{
    var domainEvents = ChangeTracker
        .Entries<Entity>()
        .Select(entry => entry.Entity)
        .SelectMany(entity =>
        {
            var domainEvents = entity.GetDomainEvents();

            entity.ClearDomainEvents();

            return domainEvents;
        })
        .ToList();

    foreach (var domainEvent in domainEvents)
    {
        await _publisher.Publish(domainEvent);
    }
}
Enter fullscreen mode Exit fullscreen mode

How To Handle Domain Events

With all of the plumbing we created so far, we're ready to implement a handler for the domain events. Luckily, this is the simplest step in the process.

All you have to do is define a class implementing INotificationHandler<T> and specify your domain event type as the generic argument.

Here's a handler for the CourseCompletedDomainEvent, which takes the domain event and publishes a CourseCompletedIntegrationEvent to notify other systems.

public class CourseCompletedDomainEventHandler
    : INotificationHandler<CourseCompletedDomainEvent>
{
    private readonly IBus _bus;

    public CourseCompletedDomainEventHandler(IBus bus)
    {
        _bus = bus;
    }

    public async Task Handle(
        CourseCompletedDomainEvent domainEvent,
        CancellationToken cancellationToken)
    {
        await _bus.Publish(
            new CourseCompletedIntegrationEvent(domainEvent.CourseId),
            cancellationToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

In Summary

Domain events can help you build a loosely coupled system. You can use them to separate the core domain logic from the side effects, which can be handled asynchronously.

There's no need to reinvent the wheel for implementing domain events, and you can use the EF Core and MediatR libraries to build this.

You will have to make the decision when you want to publish domain events. Publishing before or after saving changes to the database both have their set of tradeoffs.

I prefer publishing domain events after saving changes to the database, and I use the Outbox patternto add transactional guarantees. This approach introduces eventual consistency, but it's also more reliable.

Hope this was helpful.

See you next week!

Today's action step: Take a look at this video, where I explain how to implement domain events to build a decoupled system that scales.


P.S. Whenever you're ready, there are 2 ways I can help you:

  1. Pragmatic Clean Architecture: This comprehensive course will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture. Join 1,000+ students here.

  2. Patreon Community: Think like a senior software engineer with access to the source code I use in my YouTube videos and exclusive discounts for my courses. Join 850+ engineers here.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .