Skip to main content
SEMastery

How to Use Domain Events to Build Loosely Coupled Systems in .NET

Learn how domain events keep .NET code loosely coupled. A simple analogy, full C# examples, diagrams, timing tips, and common mistakes explained for beginners.

13 min readUpdated February 14, 2026

The wedding planner and the announcement

Imagine a big Indian wedding. The family decides the wedding date. Now many people must act on that one decision. The cook must plan the food. The decorator must book flowers. The pandit must be told the muhurat. The relatives must get their invitation cards.

Think about two ways this could happen.

In the first way, the father of the bride personally calls the cook, then the decorator, then the pandit, then every relative, one by one. If he forgets one phone number, that person is left out. If a new helper joins next week, he must remember to call them too. His phone book keeps growing, and he is doing everyone's job.

In the second way, the family hires a wedding planner. The father simply tells the planner: "The wedding date is fixed for 12th December." That is all. The planner then quietly informs the cook, the decorator, the pandit, and prints the cards. The father does not need to know who does what. He just announced what happened, and the right people reacted.

A domain event is exactly that announcement. When something important happens in your software — an order is placed, a user signs up, a payment fails — your code raises one small event that says "this happened". Other parts of the app listen and react on their own. The part that raised the event never needs to know who is listening. This is what we mean by a loosely coupled system: the pieces are connected through events, not through direct phone calls.

In this lesson, you will learn what domain events are, why they reduce coupling, and how to build them in .NET with clean, beginner-friendly C# code.

What does "loosely coupled" even mean?

Two pieces of code are tightly coupled when one cannot work or change without the other. If method A directly calls method B, C, and D, then A knows about all three. Change one of them, and you might break A.

Two pieces are loosely coupled when they can change on their own. They talk through a small, stable message instead of direct calls. That message is the event.

Here is the difference in one picture.

Tight coupling means one method calls every other piece directly. Loose coupling lets one event reach many listeners.

On the left, PlaceOrder must know about three other services. On the right, it only knows about one event. The listeners take care of themselves.

Why not just call everything directly?

Let us make this real. Picture an online shop. When an order is placed, three things should happen:

  1. Reduce the stock count for each item.
  2. Give the customer loyalty points.
  3. Send a confirmation email.

The tempting way is to jam all of it into one method.

public async Task PlaceOrder(Order order)
{
    _orders.Add(order);
 
    // Everything crammed into one place
    await _stockService.Reduce(order.Items);
    await _loyaltyService.AddPoints(order.CustomerId, order.Total);
    await _emailService.SendConfirmation(order);
 
    await _db.SaveChangesAsync();
}

This works on day one. But it grows ugly fast. Next month the boss says, "Also notify the warehouse app." Then, "Also update the sales dashboard." Soon this one method calls eight services. It is hard to read, hard to test, and every new rule means editing the same crowded method. The order logic is now tangled with email logic, points logic, and warehouse logic.

This is the father-of-the-bride problem. He is calling everyone himself. We want a wedding planner.

The same shop with domain events

With domain events, PlaceOrder does only its own job: it creates the order and raises an event that says OrderPlaced. Each other concern becomes its own small handler that listens for that event.

public sealed class Order : Entity
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public decimal Total { get; private set; }
 
    public static Order Place(Guid customerId, decimal total)
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = customerId,
            Total = total
        };
 
        // Pin the notice. We do not call anyone directly.
        order.Raise(new OrderPlacedDomainEvent(order.Id, customerId, total));
        return order;
    }
}

The Order does not import the email service, the stock service, or anything else. It just announces what happened. That single change is what makes the system loosely coupled.

From a fat method to clean events

Fat method
Raise event
Listeners react

Steps

1

Fat method

Order code calls every service itself

2

Raise event

Order code only raises OrderPlaced

3

Listeners react

Stock, email, points each listen alone

The order code shrinks to one job. Every other rule moves into its own listener.

The building blocks

A domain events setup in .NET has four small parts. Let us name them clearly before we write more code.

PartJobEveryday match
Domain eventA message that says what happenedThe wedding announcement
Entity base classLets an entity collect events it raisedThe notice board
HandlerCode that reacts to one eventThe cook, decorator, pandit
DispatcherFinds events and delivers them to handlersThe wedding planner

Each part is tiny. Together they give you a clean, decoupled flow. Here is the marker interface that every domain event will implement.

public interface IDomainEvent
{
}
 
public sealed record OrderPlacedDomainEvent(
    Guid OrderId,
    Guid CustomerId,
    decimal Total) : IDomainEvent;

A record is perfect here because an event describes a fact that already happened. Facts do not change, and records are easy to make read-only. Notice the past tense in the name: OrderPlaced, not PlaceOrder. Events are always named in the past tense because they report history, not commands.

Letting entities raise events

Entities need a place to keep the events they raise until someone dispatches them. We put that small list in a shared base class.

public abstract class Entity
{
    private readonly List<IDomainEvent> _domainEvents = new();
 
    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
 
    protected void Raise(IDomainEvent domainEvent) =>
        _domainEvents.Add(domainEvent);
 
    public void ClearDomainEvents() => _domainEvents.Clear();
}

When Order.Place calls Raise(...), the event sits quietly in this list. Nothing happens yet. The events are like notices waiting on the board. Later, the dispatcher will read them and call the handlers. This delay is important: we want all the work to happen at a clear, controlled moment, not in the middle of building the entity.

Writing handlers

A handler is a small class that cares about exactly one event. Each handler does one thing. The stock handler reduces stock. The email handler sends mail. They never know about each other.

public interface IDomainEventHandler<in TEvent>
    where TEvent : IDomainEvent
{
    Task Handle(TEvent domainEvent, CancellationToken ct);
}
 
public sealed class SendConfirmationEmailHandler
    : IDomainEventHandler<OrderPlacedDomainEvent>
{
    private readonly IEmailService _email;
 
    public SendConfirmationEmailHandler(IEmailService email) => _email = email;
 
    public Task Handle(OrderPlacedDomainEvent e, CancellationToken ct) =>
        _email.SendConfirmation(e.OrderId, e.CustomerId, ct);
}

Want to add a warehouse notification next month? You write a brand new handler and register it. You do not touch Order, and you do not touch the email handler. That is the whole point. New behaviour is added by adding code, not by editing old code. This is loose coupling paying you back.

One event reaches many independent handlers. Each one can be added or removed without touching the others.

The dispatcher: our wedding planner

The dispatcher is the planner. It collects all events from all changed entities and hands each event to the matching handlers. Using the built-in dependency injection container, it can find every handler for a given event type.

public sealed class DomainEventDispatcher
{
    private readonly IServiceProvider _provider;
 
    public DomainEventDispatcher(IServiceProvider provider) =>
        _provider = provider;
 
    public async Task Dispatch(
        IEnumerable<IDomainEvent> events, CancellationToken ct)
    {
        foreach (var domainEvent in events)
        {
            var handlerType = typeof(IDomainEventHandler<>)
                .MakeGenericType(domainEvent.GetType());
 
            var handlers = _provider.GetServices(handlerType);
 
            foreach (dynamic handler in handlers)
            {
                await handler.Handle((dynamic)domainEvent, ct);
            }
        }
    }
}

This is small on purpose. It loops through events, finds the handlers for each one, and calls them. The order code stays clean, and the dispatcher does the boring delivery work, just like a planner running between vendors.

When should events be dispatched?

This is the most important timing question, and beginners often get it wrong. We usually hook the dispatcher into Entity Framework Core's SaveChangesAsync. Right before or right after the data is saved, we grab the events and dispatch them.

The dispatch step sits inside SaveChanges, so saving data and reacting to it happen together in a clear order.

Here is a clean way to wire it into the DbContext.

public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
    // Find every entity that raised events
    var entities = ChangeTracker.Entries<Entity>()
        .Select(e => e.Entity)
        .Where(e => e.DomainEvents.Count > 0)
        .ToList();
 
    var events = entities.SelectMany(e => e.DomainEvents).ToList();
 
    // Clear so we never dispatch the same event twice
    entities.ForEach(e => e.ClearDomainEvents());
 
    var result = await base.SaveChangesAsync(ct);
 
    await _dispatcher.Dispatch(events, ct);
    return result;
}

The choice of "before save" versus "after save" matters, so here is a simple comparison.

TimingWhat it meansBest for
Before SaveChangesHandlers run inside the same transactionHandlers that add more database changes that must commit together
After SaveChangesHandlers run only once data is safely storedHandlers that send emails or call other services

A common and safe rule: handle in-process events that change data before the commit, and trigger anything that leaves your service (emails, other apps) after the commit. The example above dispatches after the save, which suits email-style work.

Registering everything in .NET

In your Program.cs, register the dispatcher and every handler. On .NET 10 with the modern hosting setup, this is short.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddScoped<DomainEventDispatcher>();
 
builder.Services.AddScoped<
    IDomainEventHandler<OrderPlacedDomainEvent>,
    SendConfirmationEmailHandler>();
 
builder.Services.AddScoped<
    IDomainEventHandler<OrderPlacedDomainEvent>,
    ReduceStockHandler>();
 
var app = builder.Build();

You can register many handlers for the same event. They will all run. To avoid writing each one by hand, teams often scan the assembly and register all IDomainEventHandler<> implementations automatically with a small reflection helper or a library like Scrutor.

Domain events versus integration events

Beginners often mix these two up. They are cousins, not twins.

A domain event stays inside one service and one process. It is handled in memory, usually right after you save. The OrderPlaced handlers above are domain event handlers.

An integration event leaves your service. It travels over a message broker like RabbitMQ or Azure Service Bus to other services, maybe a shipping app or an analytics app. Those other apps may be written by other teams.

The two work as a chain. A domain event handler often creates an integration event and hands it to the broker. To do that safely, teams use the Outbox Pattern, which saves the outgoing message in the same database transaction so it is never lost.

From inside to outside

Domain event
In-process handler
Integration event
Other service

Steps

1

Domain event

Raised and handled inside one service

2

In-process handler

Reacts in memory after save

3

Integration event

Built and stored via the Outbox

4

Other service

Receives it over a message broker

A domain event stays in-process; it can spawn an integration event that travels to other services.

A word on tools and licensing

You may have heard of MediatR and MassTransit. Both are popular for in-process and cross-service messaging. Be aware that both are now commercially licensed. Small projects often qualify for free use, but larger companies above a revenue threshold must buy a license. So check the terms before you depend on them at work.

The good news is that domain events do not require any paid tool. As you saw above, the whole dispatcher fits in a few dozen lines. Writing your own keeps you free of licensing worries and, honestly, teaches you more about how the pattern works. If you want a ready-made free option, plain .NET dependency injection plus a small dispatcher, as shown here, is all you need.

Common mistakes to avoid

Even with a clean pattern, a few traps catch beginners. Watch out for these.

  • Naming events as commands. Use the past tense. OrderPlaced is an event. PlaceOrder is a command. Mixing them confuses readers.
  • Putting heavy work directly in the entity. The entity should only Raise the event. Let handlers do the real work, like sending email.
  • Forgetting to clear events. If you do not clear the list after dispatching, the same event may fire twice on the next save.
  • Doing slow work inside the save transaction. Sending an email inside the transaction can hold a database lock for too long. Prefer to dispatch external work after the commit.
  • Letting one handler throw and kill the rest. Decide early how you handle errors so one broken handler does not stop the others.

A full mental model

Let us put the whole flow together one last time, end to end, so the picture is firm in your mind.

The full journey: an action raises an event, the save triggers the dispatcher, and each handler reacts on its own.

Read it like a story. Something happens. The entity raises an event and the event waits. You save your work. The save triggers the dispatcher. The dispatcher hands the event to each listener, and they all react on their own. The original code never knew or cared who was listening. That freedom is the gift of domain events.

Quick recap

  • A domain event is a small message saying something important happened, named in the past tense like OrderPlaced.
  • Events make code loosely coupled: the code that raises an event does not call listeners directly, so you can add or remove behaviour without editing old code.
  • The four parts are the event, the entity base class that holds raised events, the handlers that react, and the dispatcher that delivers.
  • The dispatcher usually runs inside SaveChangesAsync. Dispatch in-process data changes before the commit and external work after.
  • A domain event stays inside one service; an integration event travels to other services, often via the Outbox Pattern.
  • MediatR and MassTransit are now commercially licensed, but you can build a free dispatcher yourself in just a few dozen lines.

References and further reading

Related Patterns