Skip to main content
SEMastery

Building a Custom Domain Events Dispatcher in .NET (No MediatR Needed)

Build your own domain events dispatcher in .NET with EF Core. Simple analogy, full C# code, diagrams, and timing tips — no paid MediatR license required.

14 min readUpdated December 7, 2025

The school notice board

Picture a school with a big notice board near the main gate. When something important happens — a sports day is announced, or a holiday is declared — the principal does not walk to every single classroom to tell each teacher one by one. That would be slow and tiring. Instead, the principal pins one notice on the board.

Then, whoever cares reads it and acts. The sports teacher reads "Sports Day on Friday" and starts booking the ground. The class teacher reads it and tells the students. The canteen owner reads it and orders extra snacks. The principal does not need to know who reacts or how. The principal just announces what happened.

A domain event works exactly like that pinned notice. When something important happens inside your business — an order is placed, a user signs up — your code raises one small event that says "this happened". Other parts of the app read it and react on their own. The part that raised the event does not need to know who is listening.

The piece that takes all the pinned notices and makes sure the right readers see them is the dispatcher. In this lesson, we will build our own dispatcher in .NET, step by step, without any paid library.

Why not just call everything directly?

Imagine an online shop. When an order is placed, three things should happen:

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

The tempting way is to stuff all of it into one method:

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

This looks fine on day one. But it slowly becomes a headache:

  • The PlaceOrder method now knows about everything — stock, loyalty, email. It is tightly coupled.
  • Adding a new reaction (say, fraud checks) means editing this method again and again.
  • Testing PlaceOrder means mocking four services.
  • The order logic and the side effects are tangled together.

A domain event untangles this. The Order simply says "I was placed". Separate, small handlers each do one job. Let us see the shape of this idea first.

One event, many independent reactions. The order does not know who is listening.

A quick note on MediatR

Many older tutorials use a library called MediatR to do this. It worked well for years. But MediatR (and MassTransit) moved to a commercial license, so larger companies may now have to pay to use newer versions. That is a perfectly fair choice by the authors — building software costs money.

Still, a domain events dispatcher is small. You can write your own in under a hundred lines. You remove a paid dependency, you keep full control, and you understand every line. That is what we will do here, using plain .NET 10 and C# 14.

The three building blocks

A custom dispatcher needs only three simple parts. Here they are side by side.

Building blockWhat it isJob
IDomainEventA tiny marker interfaceSays "this class is a domain event"
IDomainEventHandler<T>An interface with a Handle methodCode that reacts to one event type
IDomainEventDispatcherThe post officeFinds the right handlers and calls them

We will also need a base entity that can collect events, and a hook into EF Core that fires them at the right moment. Let us build each part now.

The Parts of a Domain Events System

Entity raises event
Event stored on entity
EF Core SaveChanges
Dispatcher reads events
Handlers react

Steps

1

Raise

Entity adds an event to its own list

2

Store

Events wait quietly until save time

3

Save

EF Core SaveChanges triggers the dispatcher

4

Dispatch

Dispatcher finds handlers for each event

5

React

Each handler does one small job

Each part has one clear job. Together they replace a tangle of direct calls.

Step 1: Define the event interface

First, a marker. It holds no data of its own. It just labels a class as an event.

// The marker every domain event implements.
public interface IDomainEvent
{
    // We add a timestamp so we always know WHEN it happened.
    DateTime OccurredOnUtc { get; }
}
 
// A real event. It is a small, immutable record.
public sealed record OrderPlacedEvent(Guid OrderId, decimal Total)
    : IDomainEvent
{
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}

Notice the event is a record. Records are great for events because they are immutable — once created, their values cannot change. An event describes the past, and the past never changes.

Step 2: Let entities raise events

Every entity that can raise events will share a small base class. This base class keeps a private list of events and hands them out safely.

public abstract class Entity
{
    // The events wait here until they are dispatched.
    private readonly List<IDomainEvent> _domainEvents = new();
 
    // Read-only view so outside code cannot mess with the list.
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
 
    // Entities call this when something important happens.
    protected void Raise(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
 
    // The dispatcher clears the list after it has handled the events.
    public void ClearDomainEvents() => _domainEvents.Clear();
}

Now the Order entity can raise an event from inside its own logic. This is the important bit: the event is raised where the business rule lives, not in some service far away.

public sealed class Order : Entity
{
    public Guid Id { get; private set; }
    public decimal Total { get; private set; }
 
    public static Order Place(decimal total)
    {
        var order = new Order { Id = Guid.NewGuid(), Total = total };
 
        // The order announces what happened. It does not call anyone.
        order.Raise(new OrderPlacedEvent(order.Id, order.Total));
 
        return order;
    }
}

Step 3: Write the handlers

A handler is a small class that reacts to exactly one event type. Each handler does one job. Here is the loyalty handler.

public interface IDomainEventHandler<in TEvent>
    where TEvent : IDomainEvent
{
    Task Handle(TEvent domainEvent, CancellationToken ct);
}
 
public sealed class AddLoyaltyPointsHandler
    : IDomainEventHandler<OrderPlacedEvent>
{
    private readonly ILoyaltyService _loyalty;
 
    public AddLoyaltyPointsHandler(ILoyaltyService loyalty) => _loyalty = loyalty;
 
    public async Task Handle(OrderPlacedEvent e, CancellationToken ct)
    {
        // One job, done well.
        await _loyalty.AddPoints(e.OrderId, e.Total, ct);
    }
}

You can have many handlers for the same event. A second handler could send the email, a third could update analytics. None of them know about each other. To add a new reaction tomorrow, you write a new handler class and register it — you never touch the Order again. That is the whole point.

Step 4: Build the dispatcher

The dispatcher is the post office. It takes a batch of events, and for each one, it finds every handler that cares and calls them. We use the built-in dependency injection container to find handlers, so there is no magic and no third-party library.

public interface IDomainEventDispatcher
{
    Task Dispatch(IEnumerable<IDomainEvent> events, CancellationToken ct);
}
 
public sealed class DomainEventDispatcher : IDomainEventDispatcher
{
    private readonly IServiceProvider _provider;
 
    public DomainEventDispatcher(IServiceProvider provider) => _provider = provider;
 
    public async Task Dispatch(IEnumerable<IDomainEvent> events, CancellationToken ct)
    {
        foreach (var domainEvent in events)
        {
            // Build the closed handler type, e.g. IDomainEventHandler<OrderPlacedEvent>.
            var handlerType = typeof(IDomainEventHandler<>)
                .MakeGenericType(domainEvent.GetType());
 
            // Ask the container for every handler of that type.
            var handlers = _provider.GetServices(handlerType);
 
            foreach (var handler in handlers)
            {
                // Reflection lets us call Handle without knowing the exact type.
                var method = handlerType.GetMethod("Handle")!;
                await (Task)method.Invoke(handler, new object[] { domainEvent, ct })!;
            }
        }
    }
}

This uses a little reflection, which can feel scary. But read it slowly: for each event, we ask "who handles this type?", then we call each one. That is all a dispatcher ever does.

The dispatcher finds the right handlers for each event and calls them one by one.

Step 5: Fire events at SaveChanges

Now the key question: when do we dispatch? The cleanest place is inside EF Core's SaveChanges. When you save your data, EF Core's ChangeTracker already knows every entity you touched. We can scoop up all their events right there.

public sealed class AppDbContext : DbContext
{
    private readonly IDomainEventDispatcher _dispatcher;
 
    public AppDbContext(DbContextOptions options, IDomainEventDispatcher dispatcher)
        : base(options) => _dispatcher = dispatcher;
 
    public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        // 1. Collect every event from every tracked entity.
        var entities = ChangeTracker.Entries<Entity>()
            .Select(e => e.Entity)
            .Where(e => e.DomainEvents.Count > 0)
            .ToList();
 
        var events = entities.SelectMany(e => e.DomainEvents).ToList();
 
        // 2. Clear them so we never dispatch the same event twice.
        entities.ForEach(e => e.ClearDomainEvents());
 
        // 3. Save the data first, then dispatch.
        var result = await base.SaveChangesAsync(ct);
        await _dispatcher.Dispatch(events, ct);
 
        return result;
    }
}

Here we save first, then dispatch. That is one valid choice. We will look at the timing trade-off next, because it really matters.

Before or after SaveChanges?

This is the most important decision in the whole pattern. There is no single right answer — it depends on what your handlers do.

QuestionDispatch BEFORE SaveChangesDispatch AFTER SaveChanges
Can handlers add more DB changes to the same transaction?YesNo
Do handlers run even if the save fails?Yes (risky)No (safer)
Good for sending emails or calling other services?NoYes
Good for updating other entities in the same DB?YesNo

A common, safe approach is to mix them. Dispatch in-process events that change the database before the commit, so everything saves together. Then dispatch events that talk to the outside world (emails, other services) after the commit, often by writing them to an Outbox table for reliable delivery. The diagram below shows the two timings.

Two timings for dispatching. Before-commit changes join the transaction; after-commit work runs only once data is safe.

Step 6: Wire it all up

The last piece is registration. We tell the DI container about the dispatcher and every handler. In a real app you would scan an assembly to find handlers automatically, but here is the simple, explicit version so you can see exactly what happens.

var builder = WebApplication.CreateBuilder(args);
 
// Register the dispatcher.
builder.Services.AddScoped<IDomainEventDispatcher, DomainEventDispatcher>();
 
// Register every handler against the event it handles.
builder.Services.AddScoped<IDomainEventHandler<OrderPlacedEvent>, AddLoyaltyPointsHandler>();
builder.Services.AddScoped<IDomainEventHandler<OrderPlacedEvent>, SendEmailHandler>();
 
// EF Core gets the dispatcher injected automatically.
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
 
var app = builder.Build();

To find handlers automatically instead of listing them by hand, you can scan the assembly with reflection and register each IDomainEventHandler<> you find. A small helper using Assembly.GetTypes() and AddScoped for each match keeps your Program.cs clean as the app grows.

How a request flows end to end

Let us trace one real request so the whole picture clicks into place.

Placing an Order, Start to Finish

Order.Place()
Event added to entity
SaveChangesAsync
Dispatcher runs
Loyalty + Email handlers

Steps

1

Place order

Business logic raises OrderPlacedEvent

2

Hold event

Event sits on the entity, undelivered

3

Save

EF Core collects events from the ChangeTracker

4

Dispatch

Dispatcher finds handlers via DI

5

React

Points are added and an email is queued

The order raises an event; EF Core fires it at save time; handlers react. The order never knows the handlers exist.

Domain events vs integration events

People often mix these two up. They are cousins, but they are not the same.

A domain event stays inside one service and one process. It is handled in memory, usually right away. "Order placed → add loyalty points" is a domain event.

An integration event leaves the service and travels to other services over a message broker like RabbitMQ or Azure Service Bus. "Order placed → tell the warehouse service in another app" is an integration event.

The two work together beautifully. A domain event handler often creates an integration event and saves it to an Outbox table, so it is delivered reliably even if the broker is down. So domain events are the spark, and integration events are the message that travels.

public sealed class PublishOrderPlacedHandler
    : IDomainEventHandler<OrderPlacedEvent>
{
    private readonly IOutbox _outbox;
 
    public PublishOrderPlacedHandler(IOutbox outbox) => _outbox = outbox;
 
    public async Task Handle(OrderPlacedEvent e, CancellationToken ct)
    {
        // Turn the in-process event into a message for other services.
        await _outbox.Add(new OrderPlacedIntegrationEvent(e.OrderId), ct);
    }
}

Common mistakes to avoid

A few traps catch almost everyone the first time.

  • Forgetting to clear events. If you do not call ClearDomainEvents, the same event may fire twice on the next save. Always clear after collecting.
  • Doing slow work in handlers when dispatching before the commit. Sending an email before the transaction commits can block your save and even leave you with a sent email for an order that failed to save. Move slow or external work to after the commit.
  • Throwing from a handler and breaking everything. If one handler crashes, decide on purpose what should happen. Sometimes you want the whole save to roll back; sometimes you want to log and continue. Be deliberate.
  • Making events mutable. Events describe the past. Use record types and read-only properties so no one can change history.
  • Putting business logic inside events. An event is a simple message that says what happened. The logic lives in the entity and the handlers, not the event.

When should you use this pattern?

Domain events shine when one action causes several independent reactions, and when you want those reactions to be easy to add and remove. They fit Domain-Driven Design and Clean Architecture very well.

You probably do not need them for a tiny CRUD app where one action does one thing. Adding a dispatcher there is extra weight for no benefit. Like every pattern, use it when the pain it removes is real.

Quick recap

  • A domain event is like a notice pinned to a board: the entity announces "this happened" without knowing who reacts.
  • The pattern has three small parts: an event interface, handlers, and a dispatcher.
  • Entities collect events in a base class; EF Core's SaveChanges is the natural place to fire them.
  • Before SaveChanges lets handlers join the same transaction; after SaveChanges is safer for emails and external calls. Mixing both is common.
  • You do not need MediatR, which is now commercially licensed. A custom dispatcher is under a hundred lines and keeps you in full control.
  • Domain events stay in one process; integration events travel between services, often through an Outbox for reliability.
  • Always clear events after dispatching, keep events immutable, and put logic in handlers, not in the event.

References and further reading

Related Patterns

The Outbox Pattern in .NET: Never Lose a Message Again

Learn the Outbox Pattern in .NET with simple, real-life examples. Save your data and your messages in one transaction so a broker outage can never lose an event. Includes EF Core code, diagrams, and best practices.

Read more

Event Sourcing for .NET Developers: A Simple Introduction

Learn event sourcing in .NET from scratch. Store every change as an event instead of just the current state, with a real-life bank-passbook analogy, diagrams, code, aggregates, projections, and when to use it.

Read more

The Repository Pattern in .NET: A Friendly, Complete Guide

Learn the Repository Pattern in .NET with simple real-life examples, EF Core code, diagrams, and honest advice on when to use it and when to skip it.

Read more

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.

Read more

Multi-Tenant Applications With EF Core: A Beginner's Guide

Learn multi-tenancy in EF Core the simple way. Isolate tenant data with global query filters, ITenantService, and named filters in .NET 10, with diagrams and code.

Read more

Clean Architecture Folder Structure in .NET: A Simple Guide

Learn how to organise a .NET solution with Clean Architecture. Understand the Domain, Application, Infrastructure, and Presentation layers, the dependency rule, and the exact folders, with diagrams and examples.

Read more