Domain Events — Raising, Dispatching, and Handling Side Effects
Domain events in Clean Architecture: how to raise them from entities, collect them after persistence, dispatch with a simple publisher, and handle side effects like emails and audit logs without coupling the domain.
What Domain Events Are For
A domain event represents something that happened in the domain — past tense, immutable. When a patient is admitted, when a prescription is approved, when a drug order is cancelled — these are facts that other parts of the system may need to react to.
Domain events decouple the cause (an entity state change) from the effects (send an alert, update an audit log, notify a pharmacist). The entity does not know about those side effects.
The IDomainEvent Interface
// Domain/Abstractions/IDomainEvent.cs
namespace SystemForge.Domain.Abstractions;
public interface IDomainEvent
{
Guid EventId { get; }
DateTime OccurredAt { get; }
}
// Domain/Primitives/DomainEvent.cs
public abstract record DomainEvent(
Guid EventId,
DateTime OccurredAt) : IDomainEvent
{
protected DomainEvent() : this(Guid.NewGuid(), DateTime.UtcNow) { }
}Defining Domain Events
// Domain/Events/PatientRegisteredDomainEvent.cs
public sealed record PatientRegisteredDomainEvent(
Guid EventId,
DateTime OccurredAt,
PatientId PatientId,
string MRN) : DomainEvent(EventId, OccurredAt)
{
public PatientRegisteredDomainEvent(PatientId patientId, string mrn)
: this(Guid.NewGuid(), DateTime.UtcNow, patientId, mrn) { }
}
// Domain/Events/PrescriptionAddedDomainEvent.cs
public sealed record PrescriptionAddedDomainEvent(
Guid EventId,
DateTime OccurredAt,
PatientId PatientId,
PrescriptionId PrescriptionId,
string MedicationCode) : DomainEvent(EventId, OccurredAt)
{
public PrescriptionAddedDomainEvent(
PatientId patientId,
PrescriptionId prescriptionId,
string medicationCode)
: this(Guid.NewGuid(), DateTime.UtcNow, patientId, prescriptionId, medicationCode) { }
}
// Domain/Events/DrugOrderApprovedDomainEvent.cs
public sealed record DrugOrderApprovedDomainEvent(
Guid EventId,
DateTime OccurredAt,
DrugOrderId OrderId,
string ApprovedBy) : DomainEvent(EventId, OccurredAt)
{
public DrugOrderApprovedDomainEvent(DrugOrderId orderId, string approvedBy)
: this(Guid.NewGuid(), DateTime.UtcNow, orderId, approvedBy) { }
}Raising Events in Entities
// Domain/Primitives/Entity.cs
public abstract class Entity<TId>
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> PopDomainEvents()
{
var events = _domainEvents.ToList();
_domainEvents.Clear();
return events;
}
protected void RaiseDomainEvent(IDomainEvent domainEvent)
=> _domainEvents.Add(domainEvent);
}
// Domain/Entities/Patient.cs
public static Result<Patient> Create(string name, DateOnly dob, string mrn)
{
// validation ...
var patient = new Patient { ... };
patient.RaiseDomainEvent(new PatientRegisteredDomainEvent(patient.Id, patient.MRN));
return Result.Success(patient);
}
public Result AddPrescription(Prescription prescription)
{
// validation ...
_prescriptions.Add(prescription);
RaiseDomainEvent(new PrescriptionAddedDomainEvent(Id, prescription.Id, prescription.MedicationCode.Value));
return Result.Success();
}The Publisher Interface (Application Layer)
// Application/Abstractions/IDomainEventPublisher.cs
namespace SystemForge.Application.Abstractions;
public interface IDomainEventPublisher
{
Task PublishAsync(IEnumerable<IDomainEvent> events, CancellationToken ct);
}Dispatching After SaveChanges (Infrastructure)
The standard pattern: save the aggregate first (to get a DB ID), then publish events. This keeps events in sync with committed data.
// Infrastructure/Persistence/AppDbContext.cs
public sealed class AppDbContext : DbContext
{
private readonly IDomainEventPublisher _publisher;
public AppDbContext(DbContextOptions<AppDbContext> options, IDomainEventPublisher publisher)
: base(options)
{
_publisher = publisher;
}
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
// Collect domain events before save (SaveChanges clears tracked entities)
var events = ChangeTracker
.Entries<Entity<Guid>>() // or a common base type
.Select(e => e.Entity)
.SelectMany(e =>
{
var evts = e.PopDomainEvents();
return evts;
})
.ToList();
var result = await base.SaveChangesAsync(ct);
// Dispatch after successful save
if (events.Count > 0)
await _publisher.PublishAsync(events, ct);
return result;
}
}The Publisher Implementation
// Infrastructure/Events/DomainEventPublisher.cs
public sealed class DomainEventPublisher : IDomainEventPublisher
{
private readonly IServiceProvider _services;
public DomainEventPublisher(IServiceProvider services) => _services = services;
public async Task PublishAsync(IEnumerable<IDomainEvent> events, CancellationToken ct)
{
foreach (var domainEvent in events)
{
var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(domainEvent.GetType());
var handlers = _services.GetServices(handlerType);
foreach (var handler in handlers)
{
// handler is IDomainEventHandler<T> where T is the concrete event type
await ((dynamic)handler).HandleAsync((dynamic)domainEvent, ct);
}
}
}
}
// Application/Abstractions/IDomainEventHandler.cs
public interface IDomainEventHandler<in TEvent> where TEvent : IDomainEvent
{
Task HandleAsync(TEvent domainEvent, CancellationToken ct);
}Handlers for Side Effects
// Application/Patients/Events/PatientRegisteredDomainEventHandler.cs
public sealed class PatientRegisteredDomainEventHandler
: IDomainEventHandler<PatientRegisteredDomainEvent>
{
private readonly IEmailService _email;
private readonly IAuditLog _auditLog;
public PatientRegisteredDomainEventHandler(IEmailService email, IAuditLog auditLog)
{
_email = email;
_auditLog = auditLog;
}
public async Task HandleAsync(
PatientRegisteredDomainEvent domainEvent,
CancellationToken ct)
{
// Side effect 1: audit log
await _auditLog.LogAsync(
$"Patient {domainEvent.MRN} registered at {domainEvent.OccurredAt:u}", ct);
// Side effect 2: notify pharmacy team
await _email.SendAsync(
to: "pharmacy@hospital.org",
subject: $"New patient registered: {domainEvent.MRN}",
body: $"Patient ID: {domainEvent.PatientId.Value}",
ct: ct);
}
}
// Application/DrugOrders/Events/DrugOrderApprovedDomainEventHandler.cs
public sealed class DrugOrderApprovedDomainEventHandler
: IDomainEventHandler<DrugOrderApprovedDomainEvent>
{
private readonly IPharmacyNotificationService _pharmacy;
public DrugOrderApprovedDomainEventHandler(IPharmacyNotificationService pharmacy)
=> _pharmacy = pharmacy;
public async Task HandleAsync(
DrugOrderApprovedDomainEvent domainEvent,
CancellationToken ct)
{
await _pharmacy.NotifyOrderReadyForDispensingAsync(domainEvent.OrderId, ct);
}
}DI Registration
// Infrastructure/DependencyInjection.cs
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddScoped<IDomainEventPublisher, DomainEventPublisher>();
return services;
}
// Application/DependencyInjection.cs
public static IServiceCollection AddApplication(this IServiceCollection services)
{
// Register all handlers in the Application assembly
var assembly = typeof(AssemblyReference).Assembly;
var handlerTypes = assembly.GetTypes()
.Where(t => t.GetInterfaces()
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDomainEventHandler<>)));
foreach (var handlerType in handlerTypes)
{
var interfaceType = handlerType.GetInterfaces()
.First(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDomainEventHandler<>));
services.AddScoped(interfaceType, handlerType);
}
return services;
}Key Takeaway
Domain events let entities announce that something happened, without knowing or caring what happens next. The entity raises an event; a handler reacts to it. The entity's job is to maintain its own invariants. The handler's job is to trigger side effects. They never need to know about each other — and that separation is what keeps the domain model clean as the system grows.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.