Domain Events: Decoupling Your Domain Logic
Master domain events in C# to build reactive systems with loose coupling, eventual consistency, and clean separation of concerns.
Introduction
In a monolithic procedural system, when you complete a document, you might call EmailService.Send(), AuditLog.Log(), and SearchIndex.Update() right inside your DocumentService. This creates a knot of tight coupling. Domain Events are the solution: they allow you to say “Something happened” and let other parts of the system react without the sender knowing who they are.
Why Domain Events Matter:
- Decoupling: The
Documentaggregate doesn’t need to know about email or search. It just raises an event. - Side Effects: They allow you to trigger non-transactional side effects (like sending emails) after the database commits.
- Audit Trail: The sequence of events forms a natural history of what happened in the system.
What We’ll Build
In this guide, we will implement a robust Event Dispatching system. You will learn how to:
- Define Events: Create immutable C# records representing past occurrences.
- Raise Events: Capture events inside your Aggregates.
- Dispatch Events: Use MediatR to route events to handlers.
- Handle Consistency: Execute side effects only after the transaction succeeds.
Architecture Overview
flowchart LR
subgraph Domain["🧱 Domain Layer"]
Agg["📄 Aggregate\n(Document)"] -->|Raises| Evt["📣 Event\n(DocumentCreated)"]
end
subgraph Infrastucture["⚙️ Infrastructure"]
Evt -->|Saved to| Outbox["📦 Outbox Pattern"]
Outbox -->|Dispatched via| MediatR["🚀 MediatR"]
end
subgraph Handlers["⚡ Event Handlers"]
MediatR --> H1["📧 Send Welcome Email"]
MediatR --> H2["🔍 Update Search Index"]
MediatR --> H3["📊 Update Analytics"]
end
classDef primary fill:#7c3aed,stroke:#fff,color:#fff
classDef secondary fill:#06b6d4,stroke:#fff,color:#fff
classDef db fill:#f43f5e,stroke:#fff,color:#fff
classDef warning fill:#fbbf24,stroke:#fff,color:#fff
class Domain,Handlers primary
class Infrastucture secondary
Section 1: Defining Domain Events
A Domain Event is a fact. It happened. It cannot be changed. Therefore, we use immutable record types.
// The Abstraction
public interface IDomainEvent : INotification // MediatR
{
Guid EventId { get; }
DateTimeOffset OccurredAt { get; }
}
// The Concrete Event
public sealed record DocumentCreatedEvent(
BlueRobinId DocumentId,
BlueRobinId OwnerId,
string Title) : IDomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;
}
Section 2: Raising Events in Aggregates
The Aggregate tracks the events that occur during its lifecycle. It does not dispatch them immediately; it holds them until the transaction is ready.
public abstract class AggregateRoot<TId>
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void AddDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents() => _domainEvents.Clear();
}
// Usage in Document
public void CompleteProcessing()
{
Status = ProcessingStatus.Completed;
AddDomainEvent(new DocumentProcessingCompletedEvent(Id));
}
Section 3: Dispatching (The EF Core Interceptor)
We don’t want to manually call Publish() on every service method. Instead, we hook into EF Core’s SaveChangesAsync to automatically dispatch events when the data is saved.
public sealed class DomainEventDispatcherInterceptor : SaveChangesInterceptor
{
private readonly IPublisher _mediator;
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var context = eventData.Context;
if (context is null) return await base.SavingChangesAsync(eventData, result, ct);
// 1. Find all entities with events
var aggregates = context.ChangeTracker
.Entries<AggregateRoot<BlueRobinId>>()
.Where(e => e.Entity.DomainEvents.Any())
.Select(e => e.Entity)
.ToList();
var events = aggregates
.SelectMany(a => a.DomainEvents)
.ToList();
aggregates.ForEach(a => a.ClearDomainEvents());
// 2. Dispatch events BEFORE commit (for in-process consistency)
// OR AFTER commit (for eventual consistency/side effects)
foreach (var domainEvent in events)
{
await _mediator.Publish(domainEvent, ct);
}
return await base.SavingChangesAsync(eventData, result, ct);
}
}
Section 4: Handling Events
Handlers are simple classes that react to specific events.
public sealed class DocumentCreatedHandler : INotificationHandler<DocumentCreatedEvent>
{
private readonly ILogger<DocumentCreatedHandler> _logger;
public DocumentCreatedHandler(ILogger<DocumentCreatedHandler> logger)
{
_logger = logger;
}
public Task Handle(DocumentCreatedEvent notification, CancellationToken ct)
{
_logger.LogInformation("New document created: {Id}", notification.DocumentId);
// Logic to send email, etc.
return Task.CompletedTask;
}
}
Conclusion
Domain Events give you superpowers. They allow you to write small, focused aggregates that don’t know about the rest of the world, while still driving complex workflows.
Next Steps:
- Learn how to use NATS JetStream to broadcast these events to other microservices.
- Explore Aggregates to see where these events originate.