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.
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.
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:
- Reduce the stock count for each item.
- Give the customer loyalty points.
- 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
Steps
Fat method
Order code calls every service itself
Raise event
Order code only raises OrderPlaced
Listeners react
Stock, email, points each listen alone
The building blocks
A domain events setup in .NET has four small parts. Let us name them clearly before we write more code.
| Part | Job | Everyday match |
|---|---|---|
| Domain event | A message that says what happened | The wedding announcement |
| Entity base class | Lets an entity collect events it raised | The notice board |
| Handler | Code that reacts to one event | The cook, decorator, pandit |
| Dispatcher | Finds events and delivers them to handlers | The 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.
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.
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.
| Timing | What it means | Best for |
|---|---|---|
| Before SaveChanges | Handlers run inside the same transaction | Handlers that add more database changes that must commit together |
| After SaveChanges | Handlers run only once data is safely stored | Handlers 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
Steps
Domain event
Raised and handled inside one service
In-process handler
Reacts in memory after save
Integration event
Built and stored via the Outbox
Other service
Receives it over a message broker
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.
OrderPlacedis an event.PlaceOrderis a command. Mixing them confuses readers. - Putting heavy work directly in the entity. The entity should only
Raisethe 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.
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
- Domain events: Design and implementation — Microsoft Learn
- How To Use Domain Events To Build Loosely Coupled Systems — Milan Jovanović
- Using Domain Events within a .NET Core Microservice — Cesar de la Torre (Microsoft DevBlogs)
Related Patterns
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 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.
CQRS Pattern with MediatR in .NET: A Friendly Guide
Learn the CQRS pattern with MediatR in .NET using simple words, clear diagrams, and real C# code. Beginner friendly, with pitfalls and licensing notes.
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.
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.