Skip to main content

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:

AspectDomain EventsIntegration Events
When to UseCoordinating aggregates in same contextNotifying other modules/services
TimingBefore SaveChanges (same transaction)After SaveChanges (committed)
ConsistencyStrong (ACID) - all or nothingEventual - best effort
Failure HandlingEntire operation rolls backEvent is retried automatically
InfrastructureMediatR (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:

  1. 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
  1. 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:

  1. 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(...));
  2. 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
  3. 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

  1. 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 };
  2. Is SaveChanges being called?

    • Events only fire when SaveChanges completes
    • Check that your command handler calls UnitOfWork.SaveEntitiesAsync()
  3. Are handlers registered?

    • Ensure MediatR is scanning the assembly with handlers
    • Check AddMediatR() configuration in your module

Handler Not Being Called

  1. Check event type name matches exactly (case-sensitive)
  2. Verify the bounded context folder structure is correct
  3. 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.