Domain Events Deep Dive
Domain events are a powerful pattern for coordinating changes across multiple aggregates within the same bounded context while maintaining strong consistency guarantees.
Domain Events vs Integration Events
Understanding when to use each pattern is critical:
| Aspect | Domain Events | Integration Events |
|---|---|---|
| When to Use | Coordinating aggregates in same context | Notifying other modules/services |
| Timing | Before SaveChanges (same transaction) | After SaveChanges (committed) |
| Consistency | Strong (ACID) - all or nothing | Eventual - best effort |
| Failure Handling | Entire operation rolls back | Event is retried automatically |
| Infrastructure | MediatR (INotificationHandler) | DotNetCore.CAP (ICapSubscribe) |
| Example | "When equipment created, reserve in checkout" | "When user registered, send welcome email" |
Real-World Scenario
Creating Equipment:
// Domain Event: "When equipment created, it must immediately be
// available for checkout in the same transaction"
equipment.AddDomainEvent(new EquipmentCreatedDomainEvent(...));
HandleDomainEvent -> Inventory.ReserveEquipment();
SaveChanges(); // All succeed together or all fail
// Integration Event: "After user registered, send welcome email"
// Email failure shouldn't prevent registration
user.RegisterComplete();
SaveChanges();
PublishEvent(new UserRegisteredIntegrationEvent(...));
// CAP sends email asynchronously - if it fails, CAP retries
When to Use Domain Events
✅ Use Domain Events When:
- Maintaining Cross-Aggregate Invariants
For comprehensive integration events documentation, see Integration Events with CAP.
// Equipment can't be created without capacity in checkout system var equipment = Equipment.Create(name); equipment.AddDomainEvent(new EquipmentCreatedDomainEvent(...));
The handler enforces that checkout inventory is properly reserved in the same transaction.
2. **Coordinating Atomic Changes**
```csharp
// When facility is closed, all tasks Must change status
facility.Close();
facility.AddDomainEvent(new FacilityClosedDomainEvent(...));
// Handler updates all related tasks before SaveChanges
- State Synchronization
// When user role changes, permission cache must update
user.ChangeRole(newRole);
user.AddDomainEvent(new UserRoleChangedDomainEvent(...));
// Handler clears cache before transaction commits
❌ Don't Use Domain Events When:
-
Notifying Different Modules → Use integration events
// ❌ Wrong: Domain event in Equipment module trying to notify Auth
equipment.AddDomainEvent(new EquipmentCreatedDomainEvent(...));
// ✅ Right: Integration event
integrationEventPublisher.PublishAsync(
new EquipmentCreatedIntegrationEvent(...)); -
Operations That Should Fail Independently → Use integration events
// ❌ Wrong: Email failure rolls back order creation
order.AddDomainEvent(new OrderCreatedDomainEvent(...));
// Handler tries to send email - if it fails, order creation fails
// ✅ Right: Integration event
PublishAsync(new OrderCreatedIntegrationEvent(...));
// Email sent async - order already saved successfully -
Side Effects That Can Retry Independently → Use integration events
// ❌ Wrong: If cache clear fails, entire operation fails
user.UpdateProfile(data);
user.AddDomainEvent(new UserProfileUpdatedDomainEvent(...));
// ✅ Right: Cache update handles its own retries
PublishAsync(new UserProfileUpdatedIntegrationEvent(...));
Implementation
1. Define the Event
Create an immutable record that implements IDomainEvent:
// Location: Domain/{BoundedContext}/DomainEvents/
// File: EquipmentCreatedDomainEvent.cs
namespace MyApp.Domain.Equipment.DomainEvents;
/// <summary>
/// Raised when equipment is successfully created.
/// Handlers should reserve capacity in the checkout system.
/// </summary>
public sealed record EquipmentCreatedDomainEvent(
Guid EquipmentId,
string Name,
string Category,
DateTime CreatedAt) : IDomainEvent;
Naming Convention:
- Suffix:
DomainEvent(always) - Verb tense: Past tense (Created, Updated, Deleted, Changed)
- Include all data handlers need (avoid forcing handlers to query database)
2. Raise the Event from an Aggregate
// Domain/{BoundedContext}/AggregateRoot/Equipment.cs
public class Equipment : Entity, IAggregateRoot
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public string Category { get; private set; }
// Factory method pattern
public static Equipment Create(
string name,
string category,
DateTime createdAt)
{
var equipment = new Equipment
{
Id = Guid.NewGuid(),
Name = name,
Category = category
};
// Raise the event BEFORE returning aggregate
equipment.AddDomainEvent(new EquipmentCreatedDomainEvent(
equipment.Id,
equipment.Name,
equipment.Category,
createdAt));
return equipment;
}
// Other methods...
}
Key Points:
- Raise events in factory methods or methods that mutate state
- Don't raise events in constructors - use factory methods
- Always include all relevant data in the event
3. Create Event Handler
// Location: Domain/{BoundedContext}/EventHandlers/
namespace MyApp.Domain.Equipment.EventHandlers;
using MediatR;
using MyApp.Domain.Checkout; // Other aggregate in same context
public sealed class EquipmentCreatedEventHandler :
INotificationHandler<EquipmentCreatedDomainEvent>
{
private readonly IRepositoryFactory<Inventory> inventoryRepositoryFactory;
public EquipmentCreatedEventHandler(
IRepositoryFactory<Inventory> inventoryRepositoryFactory)
{
this.inventoryRepositoryFactory = inventoryRepositoryFactory;
}
public async Task Handle(
EquipmentCreatedDomainEvent @event,
CancellationToken cancellationToken)
{
// Perform coordinating action with other aggregate
var inventory = await this.inventoryRepositoryFactory.Create()
.FindOne(
new ByIdSpecification<Inventory>(@event.EquipmentId),
cancellationToken);
if (inventory.HasValue)
{
// Reserve this equipment in the inventory system
inventory.Value.ReserveEquipment(@event.EquipmentId);
}
// DON'T call SaveChanges - command handler will do it with all changes
}
}
Handler Rules:
- Implement
INotificationHandler<TEvent> - Don't call
SaveChanges()- framework handles it - Can modify other aggregates (they'll be saved with the original aggregate)
- Exceptions here roll back the entire transaction
- Handler is synchronous within the transaction
4. Configure EF to Ignore DomainEvents Property
// In your DbContext.OnModelCreating or entity configuration:
modelBuilder.Entity<Equipment>()
.Ignore(x => x.DomainEvents); // Prevents EF from mapping events
Common Patterns
Pattern: Cascading Updates
When one aggregate changes, cascade updates to related aggregates:
public sealed class FacilityClosedEventHandler :
INotificationHandler<FacilityClosedDomainEvent>
{
private readonly IRepository<TaskAggregate> taskRepository;
public async Task Handle(
FacilityClosedDomainEvent @event,
CancellationToken cancellationToken)
{
// Find all tasks for this facility
var tasks = await this.taskRepository.FindMany(
new ByFacilityIdSpecification(@event.FacilityId),
cancellationToken);
// Update all tasks to cancelled
foreach (var task in tasks.Items)
{
task.CancelDueToFacilityClosure();
}
// Tasks are attached to repository - saved with SaveChanges
}
}
Pattern: State Synchronization
Ensure related state stays in sync across aggregates:
public sealed class UserRoleChangedEventHandler :
INotificationHandler<UserRoleChangedDomainEvent>
{
private readonly IPermissionCache permissionCache;
public async Task Handle(
UserRoleChangedDomainEvent @event,
CancellationToken cancellationToken)
{
// Invalidate cached permissions for this user
await this.permissionCache.InvalidateUserAsync(@event.UserId);
// When SaveChanges completes, cache is already invalidated
}
}
Pattern: Enrichment
Add computed/derived data before saving:
public sealed class TaskCreatedEventHandler :
INotificationHandler<TaskCreatedDomainEvent>
{
private readonly IRepository<Priority> priorityRepository;
private readonly TaskScorer taskScorer;
public async Task Handle(
TaskCreatedDomainEvent @event,
CancellationToken cancellationToken)
{
// Score the task based on criteria
var priority = await this.taskScorer.CalculateAsync(
@event,
cancellationToken);
// Update the aggregate with calculated value
// (Task aggregate fetched from repository)
var task = await this.repositoryFactory.Create()
.FindOne(
new ByIdSpecification<Task>(@event.TaskId),
cancellationToken);
task.Value.SetPriority(priority);
// Task updated before SaveChanges
}
}
Testing Domain Events
Testing with Integration Tests
[TestFixture]
public class EquipmentCreatedEventTests
{
private ICommandRunner commandRunner;
private IRepository<Inventory> inventoryRepository;
[SetUp]
public void Setup()
{
// Create test context with real database
var context = new TestDbContext();
// ... configure repositories and services
}
[Test]
public async Task WhenEquipmentCreated_InventoryIsReserved()
{
// Arrange
var command = new CreateEquipmentCommand(
Name: "Test Equipment",
Category: "Tools");
// Act
var result = await this.commandRunner.RunAsync(command);
// Assert
var inventory = await this.inventoryRepository.FindOne(
new ByEquipmentIdSpecification(result.EquipmentId),
CancellationToken.None);
Assert.That(inventory.IsReserved, Is.True);
}
}
Testing Handler in Isolation
[TestFixture]
public class EquipmentCreatedEventHandlerTests
{
[Test]
public async Task Handle_ReservesInventory()
{
// Arrange
var mockInventoryRepository = new Mock<IRepository<Inventory>>();
var handler = new EquipmentCreatedEventHandler(mockInventoryRepository);
var @event = new EquipmentCreatedDomainEvent(
Guid.NewGuid(),
"Test",
"Category",
DateTime.UtcNow);
// Act
await handler.Handle(@event, CancellationToken.None);
// Assert
mockInventoryRepository.Verify(x =>
x.FindOne(It.IsAny<ISpecification<Inventory>>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
}
Troubleshooting
Events Not Firing
-
Are you using the factory method?
// ✅ Correct - events raised
var equipment = Equipment.Create(name, category, now);
// ❌ Wrong - events not raised
var equipment = new Equipment { Name = name }; -
Is SaveChanges being called?
- Events only fire when SaveChanges completes
- Check that your command handler calls
UnitOfWork.SaveEntitiesAsync()
-
Are handlers registered?
- Ensure MediatR is scanning the assembly with handlers
- Check
AddMediatR()configuration in your module
Handler Not Being Called
- Check event type name matches exactly (case-sensitive)
- Verify the bounded context folder structure is correct
- Ensure handler implements
INotificationHandler<TEvent>exactly
Database Constraint Violations
If the handler violates database constraints, the entire transaction rolls back - this is by design. The aggregate and coordinating changes must be valid together or not at all.