Introduction
When your order gets placed, a thousand things should happen: inventory decreases, a confirmation email fires, your warehouse gets notified, your analytics dashboard updates. If you wire all those calls directly into your order controller or service, you'll quickly end up with a tangled mess of dependencies that's nearly impossible to test or change. Domain events in .NET: a practical guide for developers shows you how to let your domain model announce what happened instead of orchestrating everything from the outside.
Domain events decouple what your business logic does from how the rest of your system reacts to it. Instead of your Order aggregate knowing about email services, message queues, and read model repositories, it simply publishes an event. Your infrastructure layer picks up those events and handles the side effects. This keeps your domain clean, testable, and focused on business rules.
Why domain events matter in your architecture
Domain-driven design teaches us that the domain model should be the single source of truth about what your business does. But in real systems, business operations trigger cascading effects: placing an order triggers payment processing, inventory management, notifications, and analytics updates.
Without domain events, you end up with two patterns that both cause pain:
- Fat service classes that orchestrate domain logic, repositories, email services, message queues, and read models all at once. Testing becomes a nightmare because you have to mock everything.
- Infrastructure bleeding into aggregates where your
Orderentity knows how to send emails or publish to Kafka. This violates separation of concerns and makes your domain model harder to reason about.
Domain events solve this by letting your aggregates publish what happened, and letting external handlers decide what to do about it.
Entities raise events: OrderPlaced, PaymentFailed
A domain event is a record of something that happened in your business. It's typically named in past tense and lives in your domain layer. Let's build a concrete example with an order aggregate.
First, define your event:
public abstract class DomainEvent
{
public Guid AggregateId { get; protected set; }
public DateTime OccurredAt { get; protected set; } = DateTime.UtcNow;
}
public class OrderPlacedDomainEvent : DomainEvent
{
public OrderPlacedDomainEvent(Guid orderId, string customerId, decimal total)
{
AggregateId = orderId;
CustomerId = customerId;
Total = total;
}
public string CustomerId { get; }
public decimal Total { get; }
}
public class PaymentFailedDomainEvent : DomainEvent
{
public PaymentFailedDomainEvent(Guid orderId, string reason)
{
AggregateId = orderId;
Reason = reason;
}
public string Reason { get; }
}Now add an event collection to your aggregate root and raise events when business operations occur:
public class Order
{
private List _domainEvents = new();
public Guid Id { get; private set; }
public string CustomerId { get; private set; }
public List Lines { get; private set; } = new();
public OrderStatus Status { get; private set; }
public decimal Total { get; private set; }
public IReadOnlyList DomainEvents => _domainEvents.AsReadOnly();
public static Order Create(string customerId, List lines)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Lines = lines,
Status = OrderStatus.Pending,
Total = lines.Sum(l => l.Quantity * l.Price)
};
order._domainEvents.Add(new OrderPlacedDomainEvent(
order.Id,
order.CustomerId,
order.Total
));
return order;
}
public void MarkPaymentFailed(string reason)
{
Status = OrderStatus.PaymentFailed;
_domainEvents.Add(new PaymentFailedDomainEvent(Id, reason));
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}The key insight: your aggregate doesn't care who listens to these events. It just announces what happened. The Create factory method and MarkPaymentFailed behavior method both raise events as a side effect of domain operations.
Dispatch events after SaveChanges succeeds
Raising events in memory is the easy part. You need to actually dispatch them, but only after your database transaction succeeds. If you dispatch before saving and the save fails, external systems act on stale data.
The cleanest approach uses a domain event dispatcher in your EF Core save pipeline:
public interface IDomainEventDispatcher
{
Task DispatchAsync(DomainEvent domainEvent);
}
public class UnitOfWork
{
private readonly DbContext _dbContext;
private readonly IDomainEventDispatcher _eventDispatcher;
public UnitOfWork(DbContext dbContext, IDomainEventDispatcher eventDispatcher)
{
_dbContext = dbContext;
_eventDispatcher = eventDispatcher;
}
public async Task SaveChangesAsync()
{
var aggregates = _dbContext.ChangeTracker
.Entries()
.Where(e => e.Entity.DomainEvents.Any())
.Select(e => e.Entity)
.ToList();
var domainEvents = aggregates
.SelectMany(a => a.DomainEvents)
.ToList();
await _dbContext.SaveChangesAsync();
foreach (var domainEvent in domainEvents)
{
await _eventDispatcher.DispatchAsync(domainEvent);
}
foreach (var aggregate in aggregates)
{
aggregate.ClearDomainEvents();
}
}
}This pattern ensures:
- Events are only dispatched if the database save succeeds.
- All events from a transaction are collected before dispatch.
- Each event is processed after transactional consistency is guaranteed.
- Events are cleared so they aren't dispatched twice.
In your application service, use the unit of work:
public class PlaceOrderService
{
private readonly UnitOfWork _unitOfWork;
public PlaceOrderService(UnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task ExecuteAsync(PlaceOrderCommand command)
{
var lines = command.Items
.Select(i => new OrderLine(i.ProductId, i.Quantity, i.Price))
.ToList();
var order = Order.Create(command.CustomerId, lines);
_dbContext.Orders.Add(order);
await _unitOfWork.SaveChangesAsync();
}
}Handlers send emails, update read models, publish to Kafka
Once events are dispatched, handlers react to them. A single event can have multiple handlers, and handlers can do different things: send emails, update read models, trigger external APIs, publish to event streams.
Define handlers as simple classes:
public interface IDomainEventHandler where TEvent : DomainEvent
{
Task HandleAsync(TEvent domainEvent);
}
public class SendOrderConfirmationEmailHandler : IDomainEventHandler
{
private readonly IEmailService _emailService;
private readonly ICustomerRepository _customerRepository;
public SendOrderConfirmationEmailHandler(IEmailService emailService, ICustomerRepository customerRepository)
{
_emailService = emailService;
_customerRepository = customerRepository;
}
public async Task HandleAsync(OrderPlacedDomainEvent domainEvent)
{
var customer = await _customerRepository.GetAsync(domainEvent.CustomerId);
await _emailService.SendAsync(
customer.Email,
"Order Confirmation",
$"Your order {domainEvent.AggregateId} for ${domainEvent.Total} has been placed."
);
}
}
public class PublishOrderPlacedToKafkaHandler : IDomainEventHandler
{
private readonly IProducer _kafkaProducer;
public PublishOrderPlacedToKafkaHandler(IProducer kafkaProducer)
{
_kafkaProducer = kafkaProducer;
}
public async Task HandleAsync(OrderPlacedDomainEvent domainEvent)
{
var message = new { domainEvent.AggregateId, domainEvent.CustomerId, domainEvent.Total };
await _kafkaProducer.ProduceAsync("orders.placed", new Message
{
Key = domainEvent.AggregateId.ToString(),
Value = JsonSerializer.Serialize(message)
});
}
}
public class UpdateOrderReadModelHandler : IDomainEventHandler
{
private readonly IOrderReadModelRepository _readModelRepository;
public UpdateOrderReadModelHandler(IOrderReadModelRepository readModelRepository)
{
_readModelRepository = readModelRepository;
}
public async Task HandleAsync(OrderPlacedDomainEvent domainEvent)
{
var readModel = new OrderReadModel
{
OrderId = domainEvent.AggregateId,
CustomerId = domainEvent.CustomerId,
Total = domainEvent.Total,
PlacedAt = domainEvent.OccurredAt
};
await _readModelRepository.AddAsync(readModel);
}
}Wire up the dispatcher to find and invoke handlers using dependency injection:
public class ServiceProviderDomainEventDispatcher : IDomainEventDispatcher
{
private readonly IServiceProvider _serviceProvider;
public ServiceProviderDomainEventDispatcher(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task DispatchAsync(DomainEvent domainEvent)
{
var eventType = domainEvent.GetType();
var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(eventType);
var handlers = _serviceProvider.GetServices(handlerType);
var tasks = handlers.Cast()
.Select(handler => handler.HandleAsync((dynamic)domainEvent));
await Task.WhenAll(tasks);
}
}Register handlers in your DI container:
services.AddScoped();
services.AddScoped, SendOrderConfirmationEmailHandler>();
services.AddScoped, PublishOrderPlacedToKafkaHandler>();
services.AddScoped, UpdateOrderReadModelHandler>();
services.AddScoped, NotifyPaymentFailureHandler>();Keeps aggregates free of infrastructure code
The biggest win from domain events is that your domain model stays pure. Your Order aggregate doesn't know about email services, Kafka producers, or read models. It only knows about business rules: orders have lines, they have a total, they can be placed and fail payment.
This has real benefits:
- Testing is simple. You test that
Order.Create()raisesOrderPlacedDomainEvent. You test handlers in isolation. No massive mock setups. - Changing side effects is easy. If you want to add a new handler that updates a CRM when an order is placed, you just add the handler. The domain logic doesn't change.
- Domain experts can read your code. Non-engineers can understand what the business rules are without deciphering email templates and queue logic.
- Reusing domain logic is possible. Multiple command handlers or use cases can create orders and all side effects happen automatically.
Common mistakes
Dispatching before saving. If you dispatch events before SaveChanges, external systems will act on data that might not be persisted if the transaction fails. Always dispatch after a successful save.
Making events mutable. Events are facts. Once they happen, they shouldn't change. Use read-only properties so events are immutable historical records.
Putting too much logic in handlers. Handlers should be glue code that calls existing services. If a handler is orchestrating a complex workflow, that logic belongs in an application service or a saga, not a handler.
Forgetting to handle handler failures. If a handler throws, what happens to the other handlers? Do you retry? Use a try-catch wrapper or implement a decorator pattern to handle failures gracefully.
Dispatching synchronously when you should be async. If a handler takes a long time (sending email, calling an external API), block your HTTP response and you'll have slow endpoints. Consider async dispatch or pushing to a background job queue.
Key takeaways
- Domain events let aggregates announce what happened without knowing who's listening or what happens next.
- Dispatch events only after SaveChanges succeeds to maintain consistency with the database.
- Handlers are where side effects live: emails, Kafka messages, read model updates, external API calls.
- Multiple handlers can react to a single event, and a single event type can have many subscribers.
- Keep your domain model free of infrastructure concerns—it should only express business rules.
- Test domain logic by asserting on raised events; test handlers independently with their own mocks.
- Consider async dispatch or background jobs if handlers are slow, to avoid blocking HTTP responses.






Comments
Loading comments…