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.
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:
- Reduce the stock count.
- Give the customer loyalty points.
- 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
PlaceOrdermethod 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
PlaceOrdermeans 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.
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 block | What it is | Job |
|---|---|---|
IDomainEvent | A tiny marker interface | Says "this class is a domain event" |
IDomainEventHandler<T> | An interface with a Handle method | Code that reacts to one event type |
IDomainEventDispatcher | The post office | Finds 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
Steps
Raise
Entity adds an event to its own list
Store
Events wait quietly until save time
Save
EF Core SaveChanges triggers the dispatcher
Dispatch
Dispatcher finds handlers for each event
React
Each handler does one small job
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.
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.
| Question | Dispatch BEFORE SaveChanges | Dispatch AFTER SaveChanges |
|---|---|---|
| Can handlers add more DB changes to the same transaction? | Yes | No |
| Do handlers run even if the save fails? | Yes (risky) | No (safer) |
| Good for sending emails or calling other services? | No | Yes |
| Good for updating other entities in the same DB? | Yes | No |
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.
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
Steps
Place order
Business logic raises OrderPlacedEvent
Hold event
Event sits on the entity, undelivered
Save
EF Core collects events from the ChangeTracker
Dispatch
Dispatcher finds handlers via DI
React
Points are added and an email is queued
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
recordtypes 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
SaveChangesis 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
- Domain events: Design and implementation — Microsoft Learn
- Using Domain Events within a .NET Core Microservice — Cesar de la Torre, Microsoft DevBlogs
- How To Use Domain Events To Build Loosely Coupled Systems — Milan Jovanović
- Immediate Domain Event Salvation with MediatR — Ardalis (Steve Smith)
- Simple Domain Events with EF Core and MediatR — cfrenzel
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.
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.
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.
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.
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.
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.