👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Domain Events Pattern in .NET - Decoupling Aggregates with Event-Driven Design

Domain Events Pattern in .NET - Decoupling Aggregates with Event-Driven Design

Author - Abdul Rahman (Bhai)

DDD

22 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

In this article, we'll explore Domain Events in Domain-Driven Design and learn how to implement them in .NET applications. Domain events are records of significant business occurrences that other parts of the system need to know about—they enable loose coupling between aggregates while maintaining business consistency.

We'll discover how domain events facilitate communication between aggregates without creating tight dependencies, provide natural audit trails, enable integration with external systems, and align perfectly with how domain experts think about business processes. Events capture the language of the business in code.

Through practical examples, you'll learn how to design, publish, and handle domain events in C#, implement event-driven workflows, and build systems that react to business occurrences asynchronously while maintaining data consistency.

Why we gonna do?

The Tightly Coupled Aggregate Problem

When aggregates need to coordinate actions, developers often create direct dependencies between them. This leads to tight coupling and violation of aggregate boundaries:


// Problem: Order directly depends on Inventory
public class Order
{
    private readonly IInventoryService _inventoryService;  // ❌ Tight coupling
    
    public void Confirm()
    {
        // Order modifies another aggregate directly
        foreach (var line in _lines)
        {
            _inventoryService.ReserveStock(line.ProductId, line.Quantity);  // ❌ Wrong!
        }
        
        Status = OrderStatus.Confirmed;
    }
}

// Problems:
// ❌ Order aggregate depends on inventory infrastructure
// ❌ Two aggregates modified in same transaction (violates DDD rules)
// ❌ If inventory reservation fails, order is partially confirmed
// ❌ Can't test Order without mocking InventoryService
// ❌ Adding new reactions (email, analytics) requires changing Order

Consequences:

  • Aggregates become tightly coupled—changing one breaks others
  • Violates single responsibility—Order shouldn't know about inventory mechanics
  • Testing becomes difficult—must mock all dependencies
  • Transaction boundaries unclear—what happens if reservation fails?
  • Adding new business reactions requires modifying existing aggregates

The Invisible Audit Trail Problem

Business stakeholders often ask questions like "When was this order confirmed?" or "Who activated this license?" Without domain events, this information is scattered or missing:


// Problem: No record of what happened
public class License
{
    public LicenseStatus Status { get; private set; }
    public DateTime? LastModified { get; private set; }  // ❌ Vague
    
    public void Activate(string machineName, HardwareId hardwareId)
    {
        // Validates and activates...
        Status = LicenseStatus.Active;
        LastModified = DateTime.UtcNow;  // ❌ What changed? Who did it?
        
        // ❌ No record of the activation event itself
        // ❌ Can't answer: "When was this license activated?"
        // ❌ Can't answer: "What machine was it activated on?"
    }
}

// Questions we can't answer:
// - What happened at 2:30 PM yesterday?
// - Which licenses were activated in the last hour?
// - What triggered the status change?

Consequences:

  • No audit trail of business events
  • Can't answer "what happened and when" questions
  • Compliance and regulatory reporting is difficult
  • Business analytics require reverse-engineering from current state
  • Debugging issues requires guesswork about past events

Why Domain Events Solve These Problems

Domain events provide decoupled communication and complete audit trails:

  • Loose Coupling: Aggregates don't know about each other—they just publish events
  • Single Responsibility: Each aggregate focuses on its own rules
  • Audit Trail: Events provide a complete history of what happened
  • Easy Extension: Add new event handlers without changing existing aggregates
  • Business Alignment: Events use the ubiquitous language
  • Integration Points: External systems subscribe to events

How we gonna do?

Step 1: Define Domain Event Base Class

Create a base class for all domain events with common metadata:


public abstract class DomainEvent
{
    public Guid EventId { get; }
    public DateTime OccurredAt { get; }
    
    protected DomainEvent()
    {
        EventId = Guid.NewGuid();
        OccurredAt = DateTime.UtcNow;
    }
}

Step 2: Create Concrete Domain Events

Name events in past tense—they record things that already happened:


// License domain events
public class LicenseActivatedEvent : DomainEvent
{
    public Guid LicenseId { get; }
    public Guid CustomerId { get; }
    public HardwareId HardwareId { get; }
    public string MachineName { get; }
    public int ActivationCount { get; }
    
    public LicenseActivatedEvent(Guid licenseId, Guid customerId, 
        HardwareId hardwareId, string machineName, int activationCount)
    {
        LicenseId = licenseId;
        CustomerId = customerId;
        HardwareId = hardwareId;
        MachineName = machineName;
        ActivationCount = activationCount;
    }
}

public class LicenseSuspendedEvent : DomainEvent
{
    public Guid LicenseId { get; }
    public string Reason { get; }
    
    public LicenseSuspendedEvent(Guid licenseId, string reason)
    {
        LicenseId = licenseId;
        Reason = reason;
    }
}

// Order domain events
public class OrderConfirmedEvent : DomainEvent
{
    public Guid OrderId { get; }
    public Guid CustomerId { get; }
    public Money TotalPrice { get; }
    public IReadOnlyCollection<OrderLineData> Lines { get; }
    
    public OrderConfirmedEvent(Guid orderId, Guid customerId, 
        Money totalPrice, IReadOnlyCollection<OrderLineData> lines)
    {
        OrderId = orderId;
        CustomerId = customerId;
        TotalPrice = totalPrice;
        Lines = lines;
    }
}

// Simple DTO for event data
public record OrderLineData(Guid ProductId, string ProductName, int Quantity);

public class PaymentProcessedEvent : DomainEvent
{
    public Guid PaymentId { get; }
    public Guid OrderId { get; }
    public Money Amount { get; }
    public string PaymentMethod { get; }
    
    public PaymentProcessedEvent(Guid paymentId, Guid orderId, 
        Money amount, string paymentMethod)
    {
        PaymentId = paymentId;
        OrderId = orderId;
        Amount = amount;
        PaymentMethod = paymentMethod;
    }
}

Naming conventions:

  • Use past tense: OrderConfirmed, not ConfirmOrder
  • Entity + Action pattern: LicenseActivated, PaymentProcessed
  • Include only data consumers need—not entire object graphs
  • Make events immutable—they're historical records

Step 3: Add Event Collection to Aggregate

Aggregates collect events as they perform operations:


public class License
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public LicenseStatus Status { get; private set; }
    
    private readonly List<Activation> _activations = new();
    
    // Event collection
    private readonly List<DomainEvent> _domainEvents = new();
    public IReadOnlyCollection<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();
    
    public void Activate(string machineName, HardwareId hardwareId)
    {
        // Validate business rules
        EnsureCanBeActivated();
        EnsureNotExpired();
        EnsureActivationLimitNotReached();
        
        // Perform state change
        var activation = new Activation(Guid.NewGuid(), machineName, hardwareId);
        _activations.Add(activation);
        
        // Record domain event
        var @event = new LicenseActivatedEvent(
            Id,
            CustomerId,
            hardwareId,
            machineName,
            _activations.Count
        );
        
        _domainEvents.Add(@event);
    }
    
    public void Suspend(string reason)
    {
        if (Status == LicenseStatus.Suspended)
            throw new InvalidOperationException("Already suspended");
        
        Status = LicenseStatus.Suspended;
        
        // Record event
        _domainEvents.Add(new LicenseSuspendedEvent(Id, reason));
    }
    
    // Clear events after processing (called by infrastructure)
    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

Step 4: Create Event Handler Interface

Define how event handlers are structured:


public interface IEventHandler<in TEvent> where TEvent : DomainEvent
{
    Task Handle(TEvent @event);
}

Step 5: Implement Concrete Event Handlers

Each handler reacts to specific events:


// Handler 1: Update inventory when order confirmed
public class OrderConfirmedInventoryHandler : IEventHandler<OrderConfirmedEvent>
{
    private readonly IInventoryService _inventoryService;
    
    public OrderConfirmedInventoryHandler(IInventoryService inventoryService)
    {
        _inventoryService = inventoryService;
    }
    
    public async Task Handle(OrderConfirmedEvent @event)
    {
        // React to order confirmation by reserving inventory
        foreach (var line in @event.Lines)
        {
            await _inventoryService.ReserveStock(line.ProductId, line.Quantity);
        }
    }
}

// Handler 2: Send confirmation email
public class OrderConfirmedEmailHandler : IEventHandler<OrderConfirmedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ICustomerRepository _customerRepo;
    
    public OrderConfirmedEmailHandler(IEmailService emailService, 
        ICustomerRepository customerRepo)
    {
        _emailService = emailService;
        _customerRepo = customerRepo;
    }
    
    public async Task Handle(OrderConfirmedEvent @event)
    {
        var customer = await _customerRepo.GetByIdAsync(@event.CustomerId);
        
        await _emailService.SendOrderConfirmation(
            customer.Email,
            @event.OrderId,
            @event.TotalPrice
        );
    }
}

// Handler 3: Update analytics
public class OrderConfirmedAnalyticsHandler : IEventHandler<OrderConfirmedEvent>
{
    private readonly IAnalyticsService _analyticsService;
    
    public OrderConfirmedAnalyticsHandler(IAnalyticsService analyticsService)
    {
        _analyticsService = analyticsService;
    }
    
    public async Task Handle(OrderConfirmedEvent @event)
    {
        await _analyticsService.TrackOrderConfirmed(
            @event.OrderId,
            @event.TotalPrice.Amount,
            @event.Lines.Count
        );
    }
}

// Handler 4: Create audit log entry
public class LicenseActivatedAuditHandler : IEventHandler<LicenseActivatedEvent>
{
    private readonly IAuditLogRepository _auditRepo;
    
    public LicenseActivatedAuditHandler(IAuditLogRepository auditRepo)
    {
        _auditRepo = auditRepo;
    }
    
    public async Task Handle(LicenseActivatedEvent @event)
    {
        var auditEntry = new AuditLog
        {
            EventType = "LicenseActivated",
            EntityId = @event.LicenseId,
            OccurredAt = @event.OccurredAt,
            Data = new
            {
                @event.MachineName,
                @event.HardwareId,
                @event.ActivationCount
            }
        };
        
        await _auditRepo.AddAsync(auditEntry);
    }
}

Benefits:

  • Each handler has single responsibility
  • Easy to add new handlers without changing aggregates
  • Handlers can be tested independently
  • Different concerns (inventory, email, analytics) are separated

Step 6: Create Event Dispatcher

Implement infrastructure to dispatch events to handlers:


public interface IEventDispatcher
{
    Task DispatchAsync(DomainEvent @event);
    Task DispatchAsync(IEnumerable<DomainEvent> events);
}

public class EventDispatcher : IEventDispatcher
{
    private readonly IServiceProvider _serviceProvider;
    
    public EventDispatcher(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public async Task DispatchAsync(DomainEvent @event)
    {
        // Get all handlers for this event type
        var eventType = @event.GetType();
        var handlerType = typeof(IEventHandler<>).MakeGenericType(eventType);
        
        var handlers = _serviceProvider.GetServices(handlerType);
        
        // Invoke each handler
        foreach (var handler in handlers)
        {
            var handleMethod = handlerType.GetMethod("Handle");
            var task = (Task)handleMethod.Invoke(handler, new object[] { @event });
            await task;
        }
    }
    
    public async Task DispatchAsync(IEnumerable<DomainEvent> events)
    {
        foreach (var @event in events)
        {
            await DispatchAsync(@event);
        }
    }
}

Step 7: Dispatch Events After Saving Aggregate

Events should be dispatched after the aggregate is successfully saved:


public class OrderApplicationService
{
    private readonly IOrderRepository _orderRepo;
    private readonly IEventDispatcher _eventDispatcher;
    
    public OrderApplicationService(IOrderRepository orderRepo, 
        IEventDispatcher eventDispatcher)
    {
        _orderRepo = orderRepo;
        _eventDispatcher = eventDispatcher;
    }
    
    public async Task ConfirmOrderAsync(Guid orderId)
    {
        // 1. Load aggregate
        var order = await _orderRepo.GetByIdAsync(orderId);
        if (order == null)
            throw new OrderNotFoundException(orderId);
        
        // 2. Execute domain logic (collects events)
        order.Confirm();
        
        // 3. Save aggregate (transactional)
        await _orderRepo.SaveAsync(order);
        
        // 4. Dispatch events AFTER successful save
        await _eventDispatcher.DispatchAsync(order.DomainEvents);
        
        // 5. Clear events from aggregate
        order.ClearDomainEvents();
    }
}

Important: Dispatch events after saving to ensure consistency. If the save fails, events won't be dispatched.

Step 8: Register Handlers in Dependency Injection

Configure DI to wire up handlers:


// In Program.cs or Startup.cs
services.AddScoped<IEventDispatcher, EventDispatcher>();

// Register all event handlers
services.AddScoped<IEventHandler<OrderConfirmedEvent>, 
    OrderConfirmedInventoryHandler>();
services.AddScoped<IEventHandler<OrderConfirmedEvent>, 
    OrderConfirmedEmailHandler>();
services.AddScoped<IEventHandler<OrderConfirmedEvent>, 
    OrderConfirmedAnalyticsHandler>();

services.AddScoped<IEventHandler<LicenseActivatedEvent>, 
    LicenseActivatedAuditHandler>();
services.AddScoped<IEventHandler<LicenseSuspendedEvent>, 
    LicenseSuspendedNotificationHandler>();

// Or use assembly scanning
services.Scan(scan => scan
    .FromAssemblyOf<OrderConfirmedEvent>()
    .AddClasses(classes => classes.AssignableTo(typeof(IEventHandler<>)))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Complete Flow Example


// 1. User confirms an order
await _orderService.ConfirmOrderAsync(orderId);

// Behind the scenes:
// ↓
// 2. Order.Confirm() is called
//    - Status changes to Confirmed
//    - OrderConfirmedEvent is added to _domainEvents
// ↓
// 3. Repository saves Order to database (transaction commits)
// ↓
// 4. EventDispatcher dispatches OrderConfirmedEvent
// ↓
// 5. Multiple handlers react:
//    - OrderConfirmedInventoryHandler reserves stock
//    - OrderConfirmedEmailHandler sends confirmation email
//    - OrderConfirmedAnalyticsHandler tracks metrics
// ↓
// 6. Events are cleared from aggregate

// Result:
// ✅ Order is confirmed
// ✅ Inventory is reserved
// ✅ Email is sent
// ✅ Analytics are updated
// ✅ All without Order knowing about inventory, email, or analytics!

Querying Event History

Store events in a dedicated table for audit trails:


public class EventStore
{
    private readonly DbContext _context;
    
    public async Task SaveEventAsync(DomainEvent @event)
    {
        var eventRecord = new EventRecord
        {
            EventId = @event.EventId,
            EventType = @event.GetType().Name,
            OccurredAt = @event.OccurredAt,
            Data = JsonSerializer.Serialize(@event)
        };
        
        _context.EventRecords.Add(eventRecord);
        await _context.SaveChangesAsync();
    }
    
    public async Task<IEnumerable<DomainEvent>> GetEventsForEntityAsync(Guid entityId)
    {
        return await _context.EventRecords
            .Where(e => e.EntityId == entityId)
            .OrderBy(e => e.OccurredAt)
            .Select(e => JsonSerializer.Deserialize<DomainEvent>(e.Data))
            .ToListAsync();
    }
}

// Now you can answer business questions:
var events = await _eventStore.GetEventsForEntityAsync(licenseId);
// "When was this license activated?"
// "How many times has it been suspended?"
// "What happened yesterday?"

Summary

  • Domain events are records of significant business occurrences named in past tense
  • Events enable loose coupling between aggregates—they don't reference each other directly
  • Follow naming pattern: Entity + Action in past tense (OrderConfirmed, LicenseActivated, PaymentProcessed)
  • Aggregates collect events during operations and expose them via DomainEvents collection
  • Events should be dispatched after the aggregate is successfully saved to ensure consistency
  • Event handlers react to events independently—each handler has single responsibility
  • Easy to extend system by adding new handlers without modifying existing aggregates
  • Events provide natural audit trail and enable answering "what happened" questions
  • Use events for cross-aggregate coordination (Order → Inventory) instead of direct dependencies
  • Store events in event store for complete business history and regulatory compliance
👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • DDD
  • Domain-Driven Design
  • DDD
  • Domain Events
  • Event-Driven Architecture
  • Loose Coupling
  • .NET
  • C#
  • Tactical Patterns
  • Audit Trail
  • Event Handlers