What are Domain Events and how do you implement them in .NET?

7 minadvancedDDDdomain-eventsarchitectureMediatR

Quick Answer

Domain Events capture something meaningful that happened in the domain (e.g., OrderPlaced), enabling loose coupling: one part of the model raises an event and others react without direct dependencies. In .NET they're typically plain event classes raised by aggregates and dispatched via an in-process mediator (e.g., MediatR) or after `SaveChanges`. They keep side effects decoupled and make business-significant occurrences explicit.

Detailed Answer

Domain Events are a way to capture something important that happened in the domain. They represent business events that other parts of the system might be interested in, enabling loose coupling between different parts of the domain.

Key Characteristics:

  1. Business Meaning: Represent something important that happened
  2. Immutable: Once created, they cannot be changed
  3. Past Tense: Named as things that have already happened
  4. Rich Information: Contain all necessary data for event handlers

Basic Domain Event Implementation:

public abstract class DomainEvent
{
    public Guid Id { get; }
    public DateTime OccurredOn { get; }
    public string EventType { get; }
    
    protected DomainEvent()
    {
        Id = Guid.NewGuid();
        OccurredOn = DateTime.UtcNow;
        EventType = GetType().Name;
    }
}

// Example domain events
public class OrderConfirmedEvent : DomainEvent
{
    public OrderId OrderId { get; }
    public CustomerId CustomerId { get; }
    public Money Total { get; }
    public List<OrderItem> Items { get; }
    
    public OrderConfirmedEvent(OrderId orderId, CustomerId customerId, Money total, List<OrderItem> items)
    {
        OrderId = orderId;
        CustomerId = customerId;
        Total = total;
        Items = items;
    }
}

public class OrderCancelledEvent : DomainEvent
{
    public OrderId OrderId { get; }
    public CustomerId CustomerId { get; }
    public string Reason { get; }
    
    public OrderCancelledEvent(OrderId orderId, CustomerId customerId, string reason)
    {
        OrderId = orderId;
        CustomerId = customerId;
        Reason = reason;
    }
}

public class PaymentProcessedEvent : DomainEvent
{
    public PaymentId PaymentId { get; }
    public OrderId OrderId { get; }
    public Money Amount { get; }
    public PaymentStatus Status { get; }
    
    public PaymentProcessedEvent(PaymentId paymentId, OrderId orderId, Money amount, PaymentStatus status)
    {
        PaymentId = paymentId;
        OrderId = orderId;
        Amount = amount;
        Status = status;
    }
}

Domain Event Handler Interface:

public interface IDomainEventHandler<in TDomainEvent> where TDomainEvent : DomainEvent
{
    Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken = default);
}

// Generic handler interface
public interface IDomainEventHandler
{
    Task Handle(DomainEvent domainEvent, CancellationToken cancellationToken = default);
    bool CanHandle(DomainEvent domainEvent);
}

Event Handler Implementations:

public class OrderConfirmedEventHandler : IDomainEventHandler<OrderConfirmedEvent>
{
    private readonly IEmailService _emailService;
    private readonly IInventoryService _inventoryService;
    private readonly ILogger<OrderConfirmedEventHandler> _logger;
    
    public OrderConfirmedEventHandler(
        IEmailService emailService,
        IInventoryService inventoryService,
        ILogger<OrderConfirmedEventHandler> logger)
    {
        _emailService = emailService;
        _inventoryService = inventoryService;
        _logger = logger;
    }
    
    public async Task Handle(OrderConfirmedEvent domainEvent, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Processing order confirmation for order {OrderId}", domainEvent.OrderId);
        
        try
        {
            // Send confirmation email
            await _emailService.SendOrderConfirmationAsync(
                domainEvent.CustomerId, 
                domainEvent.OrderId, 
                domainEvent.Total);
            
            // Reserve inventory
            foreach (var item in domainEvent.Items)
            {
                await _inventoryService.ReserveInventoryAsync(
                    item.ProductId, 
                    item.Quantity);
            }
            
            _logger.LogInformation("Successfully processed order confirmation for order {OrderId}", domainEvent.OrderId);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to process order confirmation for order {OrderId}", domainEvent.OrderId);
            throw;
        }
    }
}

public class OrderCancelledEventHandler : IDomainEventHandler<OrderCancelledEvent>
{
    private readonly IEmailService _emailService;
    private readonly IInventoryService _inventoryService;
    private readonly IPaymentService _paymentService;
    
    public async Task Handle(OrderCancelledEvent domainEvent, CancellationToken cancellationToken = default)
    {
        // Send cancellation email
        await _emailService.SendOrderCancellationAsync(
            domainEvent.CustomerId, 
            domainEvent.OrderId, 
            domainEvent.Reason);
        
        // Release reserved inventory
        // Note: This would need to get the order items from somewhere
        // In a real implementation, you might include them in the event
        
        // Process refund if payment was made
        await _paymentService.ProcessRefundAsync(domainEvent.OrderId);
    }
}

public class PaymentProcessedEventHandler : IDomainEventHandler<PaymentProcessedEvent>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IShippingService _shippingService;
    
    public async Task Handle(PaymentProcessedEvent domainEvent, CancellationToken cancellationToken = default)
    {
        if (domainEvent.Status == PaymentStatus.Successful)
        {
            // Update order status
            var order = await _orderRepository.GetByIdAsync(domainEvent.OrderId);
            order.MarkAsPaid();
            await _orderRepository.SaveAsync(order);
            
            // Initiate shipping
            await _shippingService.CreateShipmentAsync(domainEvent.OrderId);
        }
        else
        {
            // Handle failed payment
            var order = await _orderRepository.GetByIdAsync(domainEvent.OrderId);
            order.MarkPaymentFailed();
            await _orderRepository.SaveAsync(order);
        }
    }
}

Domain Event Dispatcher:

public interface IDomainEventDispatcher
{
    Task DispatchAsync(DomainEvent domainEvent, CancellationToken cancellationToken = default);
    Task DispatchAsync(IEnumerable<DomainEvent> domainEvents, CancellationToken cancellationToken = default);
}

public class DomainEventDispatcher : IDomainEventDispatcher
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<DomainEventDispatcher> _logger;
    
    public DomainEventDispatcher(IServiceProvider serviceProvider, ILogger<DomainEventDispatcher> logger)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
    }
    
    public async Task DispatchAsync(DomainEvent domainEvent, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Dispatching domain event {EventType} with ID {EventId}", 
            domainEvent.EventType, domainEvent.Id);
        
        var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(domainEvent.GetType());
        var handlers = _serviceProvider.GetServices(handlerType);
        
        var tasks = handlers.Select(handler => 
        {
            var handleMethod = handler.GetType().GetMethod("Handle");
            var task = (Task)handleMethod.Invoke(handler, new object[] { domainEvent, cancellationToken });
            return task;
        });
        
        await Task.WhenAll(tasks);
        
        _logger.LogInformation("Successfully dispatched domain event {EventType} with ID {EventId}", 
            domainEvent.EventType, domainEvent.Id);
    }
    
    public async Task DispatchAsync(IEnumerable<DomainEvent> domainEvents, CancellationToken cancellationToken = default)
    {
        var tasks = domainEvents.Select(domainEvent => DispatchAsync(domainEvent, cancellationToken));
        await Task.WhenAll(tasks);
    }
}

Integration with Aggregates:

public abstract class AggregateRoot<TId> : Entity<TId> where TId : ValueObject
{
    private readonly List<DomainEvent> _domainEvents = new();
    
    public IReadOnlyList<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();
    
    protected void AddDomainEvent(DomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }
    
    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

public class Order : AggregateRoot<OrderId>
{
    public void Confirm()
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Order is not in draft status");
            
        if (_items.Count == 0)
            throw new InvalidOperationException("Cannot confirm an empty order");
            
        Status = OrderStatus.Confirmed;
        
        // Raise domain event
        AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, Total, _items.ToList()));
    }
    
    public void Cancel(string reason)
    {
        if (Status == OrderStatus.Shipped)
            throw new InvalidOperationException("Cannot cancel a shipped order");
            
        Status = OrderStatus.Cancelled;
        
        // Raise domain event
        AddDomainEvent(new OrderCancelledEvent(Id, CustomerId, reason));
    }
}

Event Publishing in Application Layer:

public class OrderApplicationService
{
    private readonly IOrderRepository _orderRepository;
    private readonly IDomainEventDispatcher _eventDispatcher;
    private readonly IUnitOfWork _unitOfWork;
    
    public async Task<OrderId> ConfirmOrderAsync(OrderId orderId)
    {
        var order = await _orderRepository.GetByIdAsync(orderId);
        if (order == null)
            throw new OrderNotFoundException(orderId);
            
        order.Confirm();
        
        await _orderRepository.SaveAsync(order);
        await _unitOfWork.CommitAsync();
        
        // Dispatch domain events after successful save
        await _eventDispatcher.DispatchAsync(order.DomainEvents);
        order.ClearDomainEvents();
        
        return order.Id;
    }
}

Event Store Implementation:

public interface IEventStore
{
    Task SaveEventsAsync(Guid aggregateId, IEnumerable<DomainEvent> events, int expectedVersion);
    Task<IEnumerable<DomainEvent>> GetEventsAsync(Guid aggregateId);
    Task<IEnumerable<DomainEvent>> GetEventsAsync(Guid aggregateId, int fromVersion);
}

public class EventStore : IEventStore
{
    private readonly ApplicationDbContext _context;
    
    public async Task SaveEventsAsync(Guid aggregateId, IEnumerable<DomainEvent> events, int expectedVersion)
    {
        var eventEntities = events.Select((domainEvent, index) => new DomainEventEntity
        {
            Id = domainEvent.Id,
            AggregateId = aggregateId,
            EventType = domainEvent.EventType,
            EventData = JsonSerializer.Serialize(domainEvent),
            Version = expectedVersion + index + 1,
            OccurredOn = domainEvent.OccurredOn
        });
        
        _context.DomainEvents.AddRange(eventEntities);
        await _context.SaveChangesAsync();
    }
    
    public async Task<IEnumerable<DomainEvent>> GetEventsAsync(Guid aggregateId)
    {
        var eventEntities = await _context.DomainEvents
            .Where(e => e.AggregateId == aggregateId)
            .OrderBy(e => e.Version)
            .ToListAsync();
            
        return eventEntities.Select(DeserializeEvent);
    }
    
    private DomainEvent DeserializeEvent(DomainEventEntity eventEntity)
    {
        var eventType = Type.GetType(eventEntity.EventType);
        return (DomainEvent)JsonSerializer.Deserialize(eventEntity.EventData, eventType);
    }
}

public class DomainEventEntity
{
    public Guid Id { get; set; }
    public Guid AggregateId { get; set; }
    public string EventType { get; set; }
    public string EventData { get; set; }
    public int Version { get; set; }
    public DateTime OccurredOn { get; set; }
}

Best Practices:

  1. Immutable Events: Domain events should be immutable
  2. Rich Information: Include all necessary data for event handlers
  3. Past Tense Naming: Use past tense for event names
  4. Single Responsibility: Each event should represent one business occurrence
  5. Async Handling: Use async/await for event handlers
  6. Error Handling: Implement proper error handling in event handlers
  7. Idempotency: Make event handlers idempotent when possible

Benefits:

  1. Loose Coupling: Reduces coupling between different parts of the system
  2. Extensibility: Easy to add new event handlers without changing existing code
  3. Audit Trail: Provides a complete history of domain events
  4. Integration: Enables integration between different bounded contexts
  5. Testing: Makes it easier to test business logic in isolation

Domain Events are a powerful pattern for creating loosely coupled, extensible systems that can evolve over time while maintaining business integrity.