Learnixo

Domain-Driven Design in .NET · Lesson 4 of 7

Domain Events — Raising, Dispatching, Handling

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 dispatched

Raising Domain Events in the Aggregate

C#
// 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

C#
// 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

C#
// 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 PrescriptionCreatedDomainEvent

Domain 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

C#
// 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. Then SaveChangesAsync() 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 after base.SaveChangesAsync() — never before. Use MediatR IPublisher.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.