How To Use Domain Events To Build Loosely Coupled Systems

How To Use Domain Events To Build Loosely Coupled Systems

7 min read ·

Thank you to our sponsors who keep this newsletter free to the reader:

Today's issue is sponsored by Pluralsight. Pluralsight's 2023 State of Cloud report comes with some surprising findings. Did you know 70% of organizations have more than half of their infrastructure in the cloud? Or that 44% of organizations adopt the latest cloud products as soon as they're available? Find out where you stand in the industry - and how to get ahead.

And by ABP Commercial. ABP Commercial is a complete web development platform built on ABP Framework, perfect for enterprise-grade ASP.NET Core based web applications. Pre-built application modules, advanced startup templates, rapid application development tooling, professional UI themes and premium support.

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:

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.

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
{
}

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

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

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

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

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

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

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 pattern to 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.


Whenever you're ready, there are 4 ways I can help you:

  1. Modular Monolith Architecture (NEW): Join 500+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  2. Pragmatic Clean Architecture: Join 2,700+ students in this comprehensive course that 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.
  3. Patreon Community: Join a community of 1,050+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.
  4. Promote yourself to 48,000+ subscribers by sponsoring this newsletter.

Become a Better .NET Software Engineer

Join 48,000+ engineers who are improving their skills every Saturday morning.