| name | notification-handlers |
| description | Use when dispatching domain events via MediatR notifications with multiple handlers.
|
| metadata | {"category":"cqrs","agent":"ef-specialist","when-to-use":"When implementing domain event dispatch using MediatR notification handlers"} |
Notification Handlers (Domain Events)
Core Principles
- Domain events represent something that happened in the domain
- Multiple handlers can react to a single event (one-to-many)
- Dispatch events after successful persistence (not before SaveChanges)
- Handlers should be idempotent for at-least-once delivery
- Events are named in past tense:
OrderCreated, OrderShipped
Patterns
Domain Event Interface
public interface IDomainEvent : INotification
{
DateTimeOffset OccurredAt { get; }
}
public abstract record DomainEvent : IDomainEvent
{
public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;
}
Concrete Domain Events
public sealed record OrderCreatedEvent(
Guid OrderId,
string CustomerName) : DomainEvent;
public sealed record OrderSubmittedEvent(
Guid OrderId,
decimal Total) : DomainEvent;
public sealed record OrderCancelledEvent(
Guid OrderId,
string Reason) : DomainEvent;
public sealed record OrderItemAddedEvent(
Guid OrderId,
Guid ProductId,
int Quantity) : DomainEvent;
Raising Events from Aggregates
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();
}
public sealed class Order : AggregateRoot
{
public static Order Create(string customerName)
{
var order = new Order { CustomerName = customerName };
order.RaiseDomainEvent(
new OrderCreatedEvent(order.Id, customerName));
return order;
}
public void Submit()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can submit");
Status = OrderStatus.Submitted;
RaiseDomainEvent(new OrderSubmittedEvent(Id, Total));
}
}
Notification Handlers
internal sealed class SendOrderConfirmationOnCreated(
IEmailService emailService,
ILogger<SendOrderConfirmationOnCreated> logger)
: INotificationHandler<OrderCreatedEvent>
{
public async Task Handle(
OrderCreatedEvent notification, CancellationToken ct)
{
logger.LogInformation(
"Sending confirmation for order {OrderId}",
notification.OrderId);
await emailService.SendOrderConfirmationAsync(
notification.OrderId, notification.CustomerName, ct);
}
}
internal sealed class UpdateDashboardOnOrderCreated(AppDbContext db)
: INotificationHandler<OrderCreatedEvent>
{
public async Task Handle(
OrderCreatedEvent notification, CancellationToken ct)
{
var stats = await db.DashboardStats.SingleAsync(ct);
stats.IncrementOrderCount();
await db.SaveChangesAsync(ct);
}
}
internal sealed class ReserveInventoryOnOrderSubmitted(
IInventoryService inventoryService)
: INotificationHandler<OrderSubmittedEvent>
{
public async Task Handle(
OrderSubmittedEvent notification, CancellationToken ct)
{
await inventoryService.ReserveForOrderAsync(
notification.OrderId, ct);
}
}
Dispatch After SaveChanges (Interceptor)
public sealed class DomainEventDispatcher(IPublisher publisher)
: SaveChangesInterceptor
{
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken ct = default)
{
var context = eventData.Context!;
var aggregates = context.ChangeTracker
.Entries<AggregateRoot>()
.Select(e => e.Entity)
.Where(e => e.DomainEvents.Count > 0)
.ToList();
var events = aggregates
.SelectMany(a => a.DomainEvents)
.ToList();
aggregates.ForEach(a => a.ClearDomainEvents());
foreach (var domainEvent in events)
await publisher.Publish(domainEvent, ct);
return result;
}
}
builder.Services.AddSingleton<DomainEventDispatcher>();
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
options.AddInterceptors(
sp.GetRequiredService<DomainEventDispatcher>());
});
Idempotent Handler
internal sealed class ProcessOrderPaymentOnSubmitted(
AppDbContext db,
IPaymentService paymentService)
: INotificationHandler<OrderSubmittedEvent>
{
public async Task Handle(
OrderSubmittedEvent notification, CancellationToken ct)
{
var alreadyProcessed = await db.PaymentRecords
.AnyAsync(p => p.OrderId == notification.OrderId, ct);
if (alreadyProcessed)
return;
await paymentService.ChargeAsync(
notification.OrderId, notification.Total, ct);
db.PaymentRecords.Add(new PaymentRecord
{
OrderId = notification.OrderId,
Amount = notification.Total,
ProcessedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
}
}
Manual Dispatch (Without Interceptor)
internal sealed class SubmitOrderHandler(
IOrderRepository repository,
IUnitOfWork unitOfWork,
IPublisher publisher)
: IRequestHandler<SubmitOrderCommand, Result>
{
public async Task<Result> Handle(
SubmitOrderCommand request, CancellationToken ct)
{
var order = await repository.FindAsync(request.OrderId, ct);
if (order is null)
return Result.Failure(
Error.NotFound("Order.NotFound", "Order not found"));
order.Submit();
await unitOfWork.SaveChangesAsync(ct);
foreach (var domainEvent in order.DomainEvents)
await publisher.Publish(domainEvent, ct);
order.ClearDomainEvents();
return Result.Success();
}
}
Anti-Patterns
- Dispatching events before SaveChanges (data may not be persisted)
- Business logic in notification handlers (keep for side effects only)
- Non-idempotent handlers with at-least-once delivery
- Throwing exceptions in handlers that block other handlers
- Synchronous cross-service calls in notification handlers
Detect Existing Patterns
- Search for
INotification and INotificationHandler< implementations
- Look for
IDomainEvent or DomainEvent base types
- Check for
RaiseDomainEvent or AddDomainEvent in entities
- Look for
SaveChangesInterceptor that dispatches events
- Search for
IPublisher.Publish calls
Adding to Existing Project
- Define
IDomainEvent interface extending INotification
- Add event collection to aggregate root base class
- Create concrete events for key domain state changes
- Add dispatch interceptor to DbContext configuration
- Create notification handlers for side effects (email, analytics, etc.)
- Ensure idempotency in all handlers
References