
Domain Events Pattern in .NET - Decoupling Aggregates with Event-Driven Design
Author - Abdul Rahman (Bhai)
DDD
22 Articles
Table of Contents
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