Domain Events
Domain events enable transactional coordination between aggregates within your bounded context. They execute before SaveChanges() in the same database transaction, allowing handlers to block or rollback operations.
What are Domain Events?
Domain events represent significant occurrences in your business domain:
- Transactional: Execute before SaveChanges in the same transaction
- Blocking: Handlers can fail and rollback the operation
- Synchronous: Processed immediately via MediatR
- Internal: Coordination within the same bounded context
- Strong Consistency: All changes succeed or fail together
Use domain events when:
- Coordinating aggregates within the same bounded context
- Maintaining invariants that require strong consistency
- Operations that should rollback if coordination fails
Don't use domain events for:
- Cross-module communication (use integration events)
- External notifications (use integration events)
- Operations that shouldn't block the user's request
Domain Events vs Integration Events
| Aspect | Domain Events | Integration Events |
|---|---|---|
| When | Before SaveChanges | After SaveChanges |
| Transaction | Same transaction | Separate transaction |
| Consistency | Strong | Eventual |
| Can Rollback | Yes (throw exception) | No (already committed) |
| Scope | Within bounded context | Cross-module |
| Library | MediatR | DotNetCore.CAP |
| Delivery | Synchronous | Asynchronous |
Creating Domain Events
Step 1: Define the Event
Events are immutable records implementing IDomainEvent:
// Location: Domain/EquipmentAggregate/DomainEvents/EquipmentCreatedDomainEvent.cs
using PearDrop.Domain;
namespace MyApp.Module.Domain.EquipmentAggregate.DomainEvents;
/// <summary>
/// Raised when equipment is successfully created.
/// Allows other aggregates to react within the same transaction.
/// </summary>
public sealed record EquipmentCreatedDomainEvent(
Guid EquipmentId,
string Name,
string Category,
DateTime CreatedAt) : IDomainEvent;
Naming conventions:
- Past tense: Describes what happened (Created, Updated, Deleted, StatusChanged)
- Suffix "DomainEvent":
EquipmentCreatedDomainEvent, notEquipmentCreated - Include all data handlers need: Avoid forcing handlers to query for data
Include enough data in the event so handlers don't need to query:
// ✅ GOOD: Handler has all needed data
public sealed record EquipmentCheckedOutDomainEvent(
Guid EquipmentId,
string EquipmentName, // Included for notifications
string Category, // Needed for inventory tracking
Guid UserId,
DateTime CheckoutDate) : IDomainEvent;
// ❌ BAD: Handler must query for name and category
public sealed record EquipmentCheckedOutDomainEvent(
Guid EquipmentId,
Guid UserId,
DateTime CheckoutDate) : IDomainEvent;
Step 2: Raise Events from Aggregates
Aggregates raise events via AddDomainEvent() method:
// Location: Domain/EquipmentAggregate/AggregateRoot/Equipment.cs
public class Equipment : Entity, IAggregateRoot
{
public string Name { get; private set; }
public string Category { get; private set; }
public EquipmentStatus Status { get; private set; }
// Private constructor for EF Core
private Equipment() { }
// Factory method for creation
public static Equipment Create(
string name,
string category,
DateTime createdAt)
{
var equipment = new Equipment
{
Id = Guid.NewGuid(),
Name = name,
Category = category,
Status = EquipmentStatus.Available,
CreatedAt = createdAt
};
// Raise domain event AFTER state change succeeds
equipment.AddDomainEvent(new EquipmentCreatedDomainEvent(
equipment.Id,
equipment.Name,
equipment.Category,
equipment.CreatedAt));
return equipment;
}
public ResultWithError<BluQubeErrorData> UpdateStatus(EquipmentStatus newStatus, DateTime timestamp)
{
if (this.Status == newStatus)
{
return ResultWithError.Ok<BluQubeErrorData>();
}
var oldStatus = this.Status;
this.Status = newStatus;
this.LastModifiedAt = timestamp;
// Event raised on state change
this.AddDomainEvent(new EquipmentStatusChangedDomainEvent(
this.Id,
oldStatus,
newStatus,
timestamp));
return ResultWithError.Ok<BluQubeErrorData>();
}
public ResultWithError<BluQubeErrorData> CheckOut(Guid userId, DateTime checkoutDate)
{
if (this.Status != EquipmentStatus.Available)
{
return ResultWithError.Fail<BluQubeErrorData>(
new BluQubeErrorData(ErrorCodes.CoreValidation, "Equipment is not available"));
}
this.Status = EquipmentStatus.CheckedOut;
this.CheckedOutBy = userId;
this.CheckedOutAt = checkoutDate;
this.AddDomainEvent(new EquipmentCheckedOutDomainEvent(
this.Id,
this.Name,
this.Category,
userId,
checkoutDate));
return ResultWithError.Ok<BluQubeErrorData>();
}
}
Key points:
- Raise events after state changes succeed
- Include all relevant data in the event
- Events are collected in the
DomainEventscollection - Events dispatched automatically before
SaveChanges()
Step 3: Create Event Handlers
Handlers coordinate changes across aggregates:
// Location: Domain/InventoryAggregate/EventHandlers/EquipmentCreatedEventHandler.cs
using MediatR;
using PearDrop.Domain.Contracts;
using MyApp.Module.Domain.EquipmentAggregate.DomainEvents;
using MyApp.Module.Domain.InventoryAggregate.AggregateRoot;
namespace MyApp.Module.Domain.InventoryAggregate.EventHandlers;
/// <summary>
/// Handles EquipmentCreatedDomainEvent by updating inventory tracking.
/// IMPORTANT: Executes BEFORE SaveChanges in the same transaction.
/// Can block/rollback the operation by throwing exceptions.
/// </summary>
internal sealed class EquipmentCreatedEventHandler(
IRepositoryFactory<Inventory> inventoryRepoFactory,
ILogger<EquipmentCreatedEventHandler> logger)
: INotificationHandler<EquipmentCreatedDomainEvent>
{
public async Task Handle(
EquipmentCreatedDomainEvent notification,
CancellationToken cancellationToken)
{
logger.LogInformation(
"Handling equipment created: {EquipmentId} in category {Category}",
notification.EquipmentId,
notification.Category);
var inventoryRepo = inventoryRepoFactory.Create();
try
{
// Load related aggregate
var inventoryMaybe = await inventoryRepo.FindOne(
new ByCategorySpecification(notification.Category),
cancellationToken);
if (inventoryMaybe.HasNoValue)
{
// Handler can fail - will roll back entire transaction
throw new InvalidOperationException(
$"Inventory not found for category {notification.Category}");
}
var inventory = inventoryMaybe.Value;
// Update related aggregate
inventory.AddEquipment(
notification.EquipmentId,
notification.Name);
// DON'T call SaveChanges here - command handler does it
// All changes saved atomically by the command handler
}
finally
{
inventoryRepo.Dispose();
}
}
}
Handler characteristics:
- Implements
INotificationHandler<TDomainEvent> - Receives notification when event is raised
- Can load other aggregates via repositories
- Can throw exceptions to rollback the entire transaction
- Does NOT call SaveChanges (command handler does this)
- Must dispose secondary repositories
Step 4: Automatic Event Dispatching
PearDrop automatically dispatches domain events before SaveChanges():
// Command handler - events dispatched automatically
protected override async Task<CommandResult> HandleInternalWithRepository(
CreateEquipmentCommand request,
CancellationToken cancellationToken)
{
// Create aggregate (raises EquipmentCreatedDomainEvent)
var equipment = Equipment.Create(
request.Name,
request.Category,
timeProvider.GetUtcNow());
this.Repository.Add(equipment);
// SaveChanges dispatches domain events FIRST, then commits
var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
// If any event handler throws, entire transaction rolls back
return result.IsSuccess
? CommandResult.Succeeded()
: CommandResult.Failed(result.Error!);
}
Execution order:
- Command handler modifies aggregates (raises events)
SaveEntitiesAsync()is called- Domain events dispatched (MediatR sends to all handlers)
- Event handlers execute (can modify other aggregates)
- If any handler throws, rollback entire transaction
- If all handlers succeed, commit transaction
- Return result to caller
Entity Configuration Requirement
CRITICAL: You must ignore DomainEvents property in EF Core configuration:
public class EquipmentConfiguration : IEntityTypeConfiguration<Equipment>
{
public void Configure(EntityTypeBuilder<Equipment> builder)
{
builder.ToTable("Equipment", "MySchema");
builder.HasKey(e => e.Id);
// Configure properties...
builder.Property(e => e.Name).IsRequired();
// CRITICAL: Ignore domain events (not persisted to database)
builder.Ignore(e => e.DomainEvents);
}
}
Without this, EF Core will try to persist the DomainEvents collection and fail.
Practical Examples
Example 1: Coordinating Task and Project Aggregates
// Task aggregate raises event
public class Task : Entity, IAggregateRoot
{
public ResultWithError<BluQubeErrorData> Complete(DateTime completedAt)
{
if (this.Status == TaskStatus.Completed)
{
return ResultWithError.Fail<BluQubeErrorData>(
new BluQubeErrorData(ErrorCodes.CoreValidation, "Task is already completed"));
}
this.Status = TaskStatus.Completed;
this.CompletedAt = completedAt;
this.AddDomainEvent(new TaskCompletedDomainEvent(
this.Id,
this.ProjectId,
completedAt));
return ResultWithError.Ok<BluQubeErrorData>();
}
}
// Project aggregate handles event
public class TaskCompletedEventHandler : INotificationHandler<TaskCompletedDomainEvent>
{
public async Task Handle(TaskCompletedDomainEvent evt, CancellationToken ct)
{
var projectRepo = projectRepoFactory.Create();
try
{
var projectMaybe = await projectRepo.FindOne(
new ByIdSpecification<Project>(evt.ProjectId),
ct);
if (projectMaybe.HasValue)
{
var project = projectMaybe.Value;
project.IncrementCompletedTaskCount();
// Check if project is now complete
if (project.AllTasksCompleted())
{
project.MarkAsComplete(DateTime.UtcNow);
}
}
}
finally
{
projectRepo.Dispose();
}
}
}
Example 2: Enforcing Business Rules
// Equipment checkout attempts to decrement inventory
public class Equipment : Entity, IAggregateRoot
{
public ResultWithError<BluQubeErrorData> CheckOut(Guid userId, DateTime checkoutDate)
{
this.Status = EquipmentStatus.CheckedOut;
this.AddDomainEvent(new EquipmentCheckedOutDomainEvent(
this.Id,
this.Category,
userId,
checkoutDate));
return ResultWithError.Ok<BluQubeErrorData>();
}
}
// Inventory handler enforces minimum stock level
public class EquipmentCheckedOutEventHandler :
INotificationHandler<EquipmentCheckedOutDomainEvent>
{
public async Task Handle(EquipmentCheckedOutDomainEvent evt, CancellationToken ct)
{
var inventory = await inventoryRepo.FindOne(
new ByCategorySpec(evt.Category),
ct);
if (inventory.Value.AvailableCount <= 1)
{
// Throw to rollback - prevents checkout if inventory too low
throw new InvalidOperationException(
$"Cannot checkout {evt.Category}: minimum inventory level reached");
}
inventory.Value.DecrementAvailable();
}
}
Best Practices
✅ DO
-
Raise events after state changes succeed
this.Status = newStatus; // Change state first
this.AddDomainEvent(new StatusChangedDomainEvent(...)); // Then raise event -
Include all necessary data in the event
// Handler doesn't need to query
public sealed record EquipmentCreatedDomainEvent(
Guid EquipmentId,
string Name, // Included
string Category, // Included
DateTime CreatedAt) : IDomainEvent; -
Keep handlers focused and simple
- One handler per event per concern
- Handlers coordinate, aggregates enforce rules
-
Make handlers idempotent when possible
public async Task Handle(Event evt, CancellationToken ct)
{
var existing = await repo.FindOne(new ByIdSpec(evt.Id));
if (existing.HasValue) return; // Already processed
// Process event
} -
Use descriptive event names
EquipmentCreatedDomainEvent✅EquipmentEvent❌
❌ DON'T
-
Don't call SaveChanges in handlers
// ❌ BAD: Handler calling SaveChanges
public async Task Handle(Event evt)
{
var item = await repo.FindOne(...);
item.Value.Update();
await repo.UnitOfWork.SaveChangesAsync(); // DON'T DO THIS
} -
Don't raise events for every property change
- Only raise events for significant domain occurrences
- Not:
NameChanged,DescriptionChanged - Yes:
EquipmentReserved,MaintenanceScheduled
-
Don't access external services in handlers
// ❌ BAD: External API call in domain event handler
public async Task Handle(Event evt)
{
await httpClient.PostAsync("https://external-api.com/notify", ...); // DON'T
}Use integration events for external notifications
-
Don't create circular event chains
- Event A → Handler raises Event B → Handler raises Event A
- Causes infinite loops
Generating Domain Events with CLI
# Generate domain event with handler
peardrop add domain-event EquipmentCreated \
--aggregate Equipment \
--properties "EquipmentId:Guid,Name:string,Category:string"
# Generated files:
# - Domain/EquipmentAggregate/DomainEvents/EquipmentCreatedDomainEvent.cs
# - Domain/EquipmentAggregate/EventHandlers/EquipmentCreatedEventHandler.cs
# Event only (no handler)
peardrop add domain-event TaskCompleted \
--aggregate Task \
--no-handler
The CLI generates:
- Event record implementing
IDomainEvent - Handler implementing
INotificationHandler<T> - Proper location structure
- XML documentation comments
- Logger injection
- Repository factory pattern
No manual configuration needed - MediatR auto-discovers handlers.
Testing Domain Events
Testing Event Raising
[Fact]
public void Create_ShouldRaiseDomainEvent()
{
// Act
var equipment = Equipment.Create("Test", "Tools", DateTime.UtcNow);
// Assert
var domainEvent = equipment.DomainEvents
.OfType<EquipmentCreatedDomainEvent>()
.SingleOrDefault();
Assert.NotNull(domainEvent);
Assert.Equal(equipment.Id, domainEvent.EquipmentId);
Assert.Equal("Test", domainEvent.Name);
}
Testing Event Handlers
[Fact]
public async Task Handle_ShouldUpdateInventory()
{
// Arrange
var mockRepoFactory = new Mock<IRepositoryFactory<Inventory>>();
var mockRepo = new Mock<IRepository<Inventory>>();
var testInventory = CreateTestInventory();
mockRepo
.Setup(r => r.FindOne(It.IsAny<Specification<Inventory>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Maybe<Inventory>.From(testInventory));
mockRepoFactory
.Setup(f => f.Create())
.Returns(mockRepo.Object);
var handler = new EquipmentCreatedEventHandler(mockRepoFactory.Object, logger);
var evt = new EquipmentCreatedDomainEvent(Guid.NewGuid(), "Test", "Tools", DateTime.UtcNow);
// Act
await handler.Handle(evt, CancellationToken.None);
// Assert
Assert.Equal(1, testInventory.TotalCount);
}
Summary
✅ Transactional: Domain events execute before SaveChanges in same transaction
✅ Blocking: Handlers can throw to rollback entire operation
✅ MediatR: Automatic discovery and dispatching
✅ Strong Consistency: All changes succeed or fail together
✅ Coordination: Used for aggregate coordination within bounded context
✅ Synchronous: Processed immediately, not queued
✅ CLI Support: Generate events and handlers with proper structure
Domain events enable safe aggregate coordination with transactional guarantees. For cross-module communication or eventual consistency scenarios, use Integration Events instead.
Further Reading
- Integration Events - Cross-module, eventual consistency
- Aggregates - Raising events from aggregate roots
- Bounded Contexts - Within-context vs cross-context communication
- Domain-Driven Design - Overall DDD approach in PearDrop