Domain Events — Raising, Dispatching, and Handling
Implement domain events in DDD: raising events from aggregate roots, collecting and dispatching them after SaveChanges, MediatR notification handlers, and domain events vs integration events for cross-service communication.
What Domain Events Are
Domain event: something important that happened in the domain.
Not a command (do this) — an event (this happened).
Examples:
PatientAdmittedDomainEvent — a patient was admitted to a ward
PrescriptionCreatedDomainEvent — a new prescription was created
InrResultRecordedDomainEvent — an INR value was recorded for a patient
Domain events are:
✓ Named in past tense — something that already happened
✓ Immutable — the fact is recorded, not mutable
✓ Raised inside the aggregate — the aggregate publishes its own events
✓ Dispatched after the transaction commits — handlers react to facts
Domain events are NOT:
✗ Commands — they don't tell other aggregates what to do
✗ Integration events — they are in-process, not cross-service
✗ Logged directly in the aggregate — they are collected and dispatchedRaising Domain Events in the Aggregate
// Base entity: collects domain events
public abstract class Entity<TId>
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent)
=> _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
// Domain event record
public sealed record PrescriptionCreatedDomainEvent(
PrescriptionId PrescriptionId,
PatientId PatientId,
string MedicationName) : IDomainEvent;
// Aggregate raises the event at the point it happens
public sealed class Prescription : Entity<PrescriptionId>
{
public static Result<Prescription> Create(
PatientId patientId, string medicationName, DosageValue dose, ClinicianId prescriberId)
{
// ... validation ...
var prescription = new Prescription { /* ... */ };
// Raise the event — collected, not dispatched yet
prescription.RaiseDomainEvent(new PrescriptionCreatedDomainEvent(
prescription.Id, patientId, medicationName));
return Result.Success(prescription);
}
public Result Dispense()
{
if (!IsActive) return Result.Failure(DomainErrors.Prescription.NotActive);
IsDispensed = true;
RaiseDomainEvent(new PrescriptionDispensedDomainEvent(Id, PatientId));
return Result.Success();
}
}Dispatching Events After SaveChanges
// Override SaveChangesAsync — dispatch events after the transaction commits
public sealed class ApplicationDbContext : DbContext
{
private readonly IPublisher _publisher;
public ApplicationDbContext(DbContextOptions options, IPublisher publisher)
: base(options)
=> _publisher = publisher;
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
// Collect all domain events from tracked entities before saving
var domainEvents = ChangeTracker.Entries<IHasDomainEvents>()
.Select(e => e.Entity)
.SelectMany(e => e.DomainEvents)
.ToList();
// Persist the changes first
var result = await base.SaveChangesAsync(ct);
// Dispatch events AFTER save — so handlers see consistent DB state
foreach (var domainEvent in domainEvents)
await _publisher.Publish(domainEvent, ct);
// Clear events so they aren't re-dispatched on next save
foreach (var entity in ChangeTracker.Entries<IHasDomainEvents>()
.Select(e => e.Entity))
entity.ClearDomainEvents();
return result;
}
}Domain Event Handlers
// Handler: send pharmacy notification when prescription is created
public sealed class SendPharmacyAlertOnPrescriptionCreated
: INotificationHandler<PrescriptionCreatedDomainEvent>
{
private readonly IPharmacyNotifier _notifier;
private readonly ILogger<SendPharmacyAlertOnPrescriptionCreated> _logger;
public async Task Handle(
PrescriptionCreatedDomainEvent notification, CancellationToken ct)
{
_logger.LogInformation(
"Sending pharmacy alert for prescription {Id}",
notification.PrescriptionId);
await _notifier.NotifyNewPrescriptionAsync(
notification.PrescriptionId, notification.PatientId, ct);
}
}
// Handler: update patient medication count in cache
public sealed class InvalidatePatientCacheOnPrescriptionCreated
: INotificationHandler<PrescriptionCreatedDomainEvent>
{
private readonly IPatientCache _cache;
public Task Handle(
PrescriptionCreatedDomainEvent notification, CancellationToken ct)
=> _cache.InvalidatePatientAsync(notification.PatientId, ct);
}
// Multiple handlers for the same domain event — each handles independently
// Both handlers receive PrescriptionCreatedDomainEventDomain Events vs Integration Events
Domain Event:
Raised and handled within the same bounded context (same process)
In-memory dispatch via MediatR IPublisher
No serialization required
Handler can access same DbContext (within the transaction scope)
Example: PrescriptionCreatedDomainEvent → update cache, send notification
Integration Event:
Published to other bounded contexts or services via message bus
Requires serialization to JSON/binary
Published AFTER the transaction commits (via outbox pattern or at-least-once delivery)
Example: PrescriptionCreated → Azure Service Bus → InventoryService, BillingService
Rule: raise domain events in aggregates → convert to integration events in handlers
when cross-service communication is needed.Outbox Pattern for Reliable Integration Event Publishing
// Problem: SaveChanges succeeds, then Service Bus publish fails → data inconsistency
// Solution: outbox — write integration event to DB in the same transaction, publish later
public sealed class SendIntegrationEventOnPrescriptionCreated
: INotificationHandler<PrescriptionCreatedDomainEvent>
{
private readonly ApplicationDbContext _db;
public async Task Handle(
PrescriptionCreatedDomainEvent notification, CancellationToken ct)
{
// Write to outbox table in the same DbContext (same transaction)
_db.OutboxMessages.Add(new OutboxMessage(
Id: Guid.NewGuid(),
EventType: nameof(PrescriptionCreatedIntegrationEvent),
Payload: JsonSerializer.Serialize(new PrescriptionCreatedIntegrationEvent(
notification.PrescriptionId.Value,
notification.PatientId.Value,
notification.MedicationName)),
CreatedAt: DateTime.UtcNow));
// No await SaveChanges — it's already in the same SaveChanges transaction
// A background worker reads OutboxMessages and publishes to Service Bus
}
}Production issue I've seen: A team dispatched domain events BEFORE
base.SaveChangesAsync(). The domain event handler sent a pharmacy SMS notification. ThenSaveChangesAsync()failed due to a concurrency conflict. The prescription was never saved, but the pharmacist had already received an SMS saying a new prescription was ready to dispense. The prescription didn't exist. Always dispatch domain events AFTER the transaction commits — the fact hasn't happened until it's persisted.
Key Takeaway
Raise domain events inside aggregates via
RaiseDomainEvent(). Dispatch them afterbase.SaveChangesAsync()— never before. Use MediatRIPublisher.Publish()to dispatch to all registered handlers. Domain events are in-process: use the outbox pattern to reliably convert them to integration events for cross-service communication. Multiple handlers per event are normal — each handles its own concern independently.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.