.NET & C# Development · Lesson 51 of 92
Domain Events — Decouple Side Effects From Your Core Logic
The Problem Domain Events Solve
When an order is confirmed in OrderFlow, several things need to happen:
- Reduce product stock levels
- Send a confirmation email to the customer
- Notify the warehouse system
- Create an audit log entry
Putting all of this inside the ConfirmOrder command handler is wrong:
// ❌ Fat handler — tight coupling, hard to test, hard to extend
public async Task Handle(ConfirmOrderCommand cmd, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(cmd.OrderId, ct);
order.Confirm();
// Now what? Email? Stock? Warehouse? Audit?
await _emailService.SendConfirmationAsync(order.Customer.Email, order);
await _stockService.ReserveStockAsync(order.Lines, ct);
await _warehouseService.NotifyAsync(order, ct);
await _auditService.LogAsync("OrderConfirmed", order.Id, ct);
await _uow.SaveChangesAsync(ct);
}Every new side effect requires changing this handler. Worse — if the email fails, did the stock reservation happen? Is the order confirmed in the database?
Domain Events solve this by letting the domain broadcast "something happened" and letting separate handlers react independently.
Domain Events in the Domain Layer
// OrderFlow.Domain/Abstractions/IDomainEvent.cs
public interface IDomainEvent : INotification { }
// OrderFlow.Domain/Abstractions/AggregateRoot.cs
public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent) =>
_domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}// OrderFlow.Domain/Events/OrderConfirmedEvent.cs
public record OrderConfirmedEvent(
Guid OrderId,
Guid CustomerId,
string CustomerEmail,
string OrderNumber,
decimal Total,
DateTime ConfirmedAt) : IDomainEvent;
// OrderFlow.Domain/Events/OrderCancelledEvent.cs
public record OrderCancelledEvent(
Guid OrderId,
string OrderNumber,
string Reason) : IDomainEvent;
// OrderFlow.Domain/Events/StockLevelChangedEvent.cs
public record StockLevelChangedEvent(
Guid ProductId,
string Sku,
int OldLevel,
int NewLevel) : IDomainEvent;Raise Events in the Domain Entity
// OrderFlow.Domain/Entities/Order.cs
public class Order : AggregateRoot
{
// ... properties ...
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException($"Order is {Status}, not Draft.");
if (_lines.Count == 0)
throw new InvalidOperationException("Cannot confirm an empty order.");
Status = OrderStatus.Confirmed;
ConfirmedAt = DateTime.UtcNow;
// Raise the event — handlers decide what to do with it
RaiseDomainEvent(new OrderConfirmedEvent(
OrderId: Id,
CustomerId: CustomerId,
CustomerEmail: Customer.Email,
OrderNumber: OrderNumber,
Total: Total,
ConfirmedAt: ConfirmedAt.Value));
}
public void Cancel(string reason = "")
{
if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
throw new InvalidOperationException("Cannot cancel a shipped order.");
Status = OrderStatus.Cancelled;
RaiseDomainEvent(new OrderCancelledEvent(Id, OrderNumber, reason));
}
}Dispatching Events After SaveChanges
The key: save first, dispatch after. This ensures the database change and the event are consistent.
// OrderFlow.Infrastructure/Persistence/AppDbContext.cs
public class AppDbContext(
DbContextOptions<AppDbContext> options,
IPublisher publisher) : IdentityDbContext<AppUser, IdentityRole<Guid>, Guid>(options), IUnitOfWork
{
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
// 1. Persist to database
var result = await base.SaveChangesAsync(ct);
// 2. Collect all raised domain events from tracked aggregates
var domainEvents = ChangeTracker
.Entries<AggregateRoot>()
.SelectMany(e => e.Entity.DomainEvents)
.ToList();
// 3. Clear events before dispatching (prevent double-dispatch)
ChangeTracker
.Entries<AggregateRoot>()
.ToList()
.ForEach(e => e.Entity.ClearDomainEvents());
// 4. Dispatch — MediatR publishes to all registered INotificationHandler<T>
foreach (var domainEvent in domainEvents)
await publisher.Publish(domainEvent, ct);
return result;
}
}Event Handlers
Each handler does one thing:
// OrderFlow.Application/Orders/Events/SendOrderConfirmationEmailHandler.cs
public class SendOrderConfirmationEmailHandler(
IEmailService emailService,
ILogger<SendOrderConfirmationEmailHandler> logger)
: INotificationHandler<OrderConfirmedEvent>
{
public async Task Handle(OrderConfirmedEvent notification, CancellationToken ct)
{
logger.LogInformation("Sending confirmation email for order {OrderNumber}",
notification.OrderNumber);
await emailService.SendAsync(new EmailMessage(
To: notification.CustomerEmail,
Subject: $"Order #{notification.OrderNumber} Confirmed",
Body: $"Your order of £{notification.Total:N2} has been confirmed."));
}
}
// OrderFlow.Application/Orders/Events/ReserveStockOnOrderConfirmedHandler.cs
public class ReserveStockOnOrderConfirmedHandler(
IOrderRepository orderRepo,
IProductRepository productRepo,
IUnitOfWork uow,
ILogger<ReserveStockOnOrderConfirmedHandler> logger)
: INotificationHandler<OrderConfirmedEvent>
{
public async Task Handle(OrderConfirmedEvent notification, CancellationToken ct)
{
var order = await orderRepo.GetByIdAsync(notification.OrderId, ct);
if (order is null) return;
foreach (var line in order.Lines)
{
var product = await productRepo.GetByIdAsync(line.ProductId, ct);
if (product is null)
{
logger.LogWarning("Product {ProductId} not found during stock reservation", line.ProductId);
continue;
}
product.AdjustStock(-line.Quantity); // reduce stock
}
await uow.SaveChangesAsync(ct);
}
}
// OrderFlow.Application/Orders/Events/AuditOrderConfirmedHandler.cs
public class AuditOrderConfirmedHandler(
IAuditService auditService)
: INotificationHandler<OrderConfirmedEvent>
{
public async Task Handle(OrderConfirmedEvent notification, CancellationToken ct) =>
await auditService.LogAsync(new AuditEntry(
Event: "OrderConfirmed",
EntityId: notification.OrderId.ToString(),
UserId: notification.CustomerId.ToString(),
OccuredAt: notification.ConfirmedAt));
}The confirm command is now clean:
// OrderFlow.Application/Orders/Commands/ConfirmOrderCommandHandler.cs
public class ConfirmOrderCommandHandler(
IOrderRepository orderRepo,
IUnitOfWork uow) : IRequestHandler<ConfirmOrderCommand>
{
public async Task Handle(ConfirmOrderCommand cmd, CancellationToken ct)
{
var order = await orderRepo.GetByIdAsync(cmd.OrderId, ct)
?? throw new KeyNotFoundException($"Order {cmd.OrderId} not found.");
order.Confirm(); // raises OrderConfirmedEvent
orderRepo.Update(order);
await uow.SaveChangesAsync(ct);
// Events are dispatched automatically by AppDbContext.SaveChangesAsync
}
}The Problem: What if the Handler Fails?
SaveChanges → ✅ Order.Status = Confirmed
Dispatch event →
- StockReservation → ✅ success
- SendEmail → ❌ email server down
- AuditLog → never reachedThe order is confirmed but the email was never sent and the audit log is missing. This is the dual-write problem.
Solution: The Outbox Pattern
Instead of dispatching events in-process, write them to an OutboxMessages table in the same transaction as the domain change. A background worker then reads and publishes them with retry.
// OrderFlow.Domain/Outbox/OutboxMessage.cs
public class OutboxMessage
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Type { get; set; } = default!; // event type name
public string Payload { get; set; } = default!; // JSON serialized event
public DateTime OccurredAt { get; set; } = DateTime.UtcNow;
public DateTime? ProcessedAt { get; set; }
public string? Error { get; set; }
public int RetryCount { get; set; }
}// Updated SaveChangesAsync — writes to outbox instead of dispatching directly
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
// Collect domain events before saving
var domainEvents = ChangeTracker
.Entries<AggregateRoot>()
.SelectMany(e => e.Entity.DomainEvents)
.ToList();
ChangeTracker.Entries<AggregateRoot>()
.ToList()
.ForEach(e => e.Entity.ClearDomainEvents());
// Serialize events to outbox — same transaction as the domain change
foreach (var domainEvent in domainEvents)
{
OutboxMessages.Add(new OutboxMessage
{
Type = domainEvent.GetType().AssemblyQualifiedName!,
Payload = JsonSerializer.Serialize(domainEvent,
domainEvent.GetType(),
new JsonSerializerOptions { WriteIndented = false }),
});
}
// Single atomic commit: domain changes + outbox messages
return await base.SaveChangesAsync(ct);
}// OrderFlow.Infrastructure/BackgroundJobs/OutboxProcessorWorker.cs
public class OutboxProcessorWorker(
IServiceScopeFactory scopeFactory,
ILogger<OutboxProcessorWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await ProcessBatchAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
private async Task ProcessBatchAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var publisher = scope.ServiceProvider.GetRequiredService<IPublisher>();
var messages = await db.OutboxMessages
.Where(m => m.ProcessedAt == null && m.RetryCount < 5)
.OrderBy(m => m.OccurredAt)
.Take(20)
.ToListAsync(ct);
foreach (var message in messages)
{
try
{
var eventType = Type.GetType(message.Type)!;
var domainEvent = (IDomainEvent)JsonSerializer.Deserialize(
message.Payload, eventType)!;
await publisher.Publish(domainEvent, ct);
message.ProcessedAt = DateTime.UtcNow;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process outbox message {Id}", message.Id);
message.Error = ex.Message;
message.RetryCount++;
}
}
await db.SaveChangesAsync(ct);
}
}Register the worker:
services.AddHostedService<OutboxProcessorWorker>();When to Use Domain Events vs Integration Events
Domain Events
✅ Side effects within the same bounded context
✅ Same process, same database
✅ Stock reservation when order confirmed
✅ Audit log entries
✅ Updating read models
Integration Events (Service Bus / RabbitMQ)
✅ Communicating across service boundaries
✅ Notifying a warehouse microservice
✅ Triggering an analytics pipeline
✅ When the handler lives in a different applicationUse domain events first. When a handler needs to cross a service boundary, promote the event to an integration event published to Service Bus.
Key Takeaways
- Domain events decouple side effects from the main command — each handler does one thing
- Raise events in the domain entity —
order.Confirm()knows what happened, not the handler - Dispatch after SaveChanges — domain change and event notification are consistent
- The Outbox Pattern solves the dual-write problem: write events and domain changes in one transaction, dispatch them asynchronously with retry
- MediatR's
INotification+INotificationHandler<T>is the ideal pairing for in-process domain events - Domain events stay within the bounded context; integration events cross service boundaries
In the Outbox Pattern, when exactly are OutboxMessages written to the database?