How to Dispatch Domain Events in C# Domain Models
Domain events are an essential building block in Domain-Driven Design (DDD) that decouple business logic from side effects by signaling that a significant business occurrence has taken place. This article explains what domain events are, demonstrates how to record them directly within your C# aggregate roots without introducing infrastructure dependencies, and shows how to cleanly dispatch them during persistence using Entity Framework Core and an event publisher.
What Are Domain Events?
A domain event is a message that records an event that has already
occurred within the domain model. By convention, they are represented as
immutable objects named in the past tense (such as
OrderCreatedEvent or
PaymentReceivedEvent).
Domain events help maintain separation of concerns. Instead of an aggregate directly calling external services—such as sending an email, notifying other bounded contexts, or updating read models—the aggregate simply raises an event. Other handlers listen for these events and perform the secondary actions asynchronously or within the same transaction.
public interface IDomainEvent
{
DateTime OccurredOn { get; }
}
public record OrderPlacedEvent(Guid OrderId, decimal TotalAmount, DateTime OccurredOn) : IDomainEvent;The Clean Approach: Recording Events in Entities
Domain entities should remain pure business objects without dependencies on dispatchers, dependency injection containers, or service locators. Injecting an event publisher or mediator directly into an entity creates unnecessary coupling and complicates unit testing.
The standard pattern is to have entities append events to an internal collection instead of dispatching them immediately. An abstract base entity class can manage this collection:
public abstract class Entity
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void AddDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}The business entity can then raise events naturally during method execution:
public class Order : Entity
{
public Guid Id { get; private set; }
public decimal TotalAmount { get; private set; }
public Order(Guid id, decimal totalAmount)
{
Id = id;
TotalAmount = totalAmount;
AddDomainEvent(new OrderPlacedEvent(Id, TotalAmount, DateTime.UtcNow));
}
}Dispatching Events with EF Core
Because the entity only records events, a mechanism is needed to
extract and publish them when changes are committed to the database. The
cleanest integration point in C# is within the Entity Framework Core
DbContext or via a SaveChangesInterceptor.
This approach ensures events are collected from tracked entities and published alongside data persistence:
public class ApplicationDbContext : DbContext
{
private readonly IMediator _mediator;
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, IMediator mediator)
: base(options)
{
_mediator = mediator;
}
public DbSet<Order> Orders => Set<Order>();
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var result = await base.SaveChangesAsync(cancellationToken);
await DispatchDomainEventsAsync();
return result;
}
private async Task DispatchDomainEventsAsync()
{
var domainEntities = ChangeTracker
.Entries<Entity>()
.Where(x => x.Entity.DomainEvents.Any())
.Select(x => x.Entity)
.ToList();
var domainEvents = domainEntities
.SelectMany(x => x.DomainEvents)
.ToList();
domainEntities.ForEach(entity => entity.ClearDomainEvents());
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent);
}
}
}Timing and Reliability Considerations
When dispatching domain events, the timing relative to the database commit determines consistency guarantees:
- Before
base.SaveChangesAsync(): Handlers run in the same transaction as the entity changes. If a handler fails, the entire transaction rolls back. This provides immediate consistency but can degrade performance if handlers perform heavy operations. - After
base.SaveChangesAsync(): The entity state is safely stored before events are fired. However, if the process crashes mid-dispatch, events can be lost. - The Outbox Pattern: For guaranteed delivery across distributed systems or external messaging tools, save domain events into an "Outbox" database table within the same transaction, then use a background worker to publish them reliably.