Instead of "controller calls service calls repository calls DbContext", you start saying things like:

"When an order is paid, the system notices and reacts."

That "notices and reacts" part? That's the Domain Event Pattern.

Let's walk through how to use domain events in DDD + Clean Architecture in .NET (8, 9, 10 — same idea), with clear layers, copy-pasteable code, and a safe outbox so you don't lose events.

1. What Is a Domain Event?

A domain event is:

Something important that has happened in your domain model that other parts of the domain might care about.

Examples:

  • OrderPaid
  • UserRegistered
  • StockDepleted
  • InvoiceOverdue

Key bits:

  • It's past tense: "OrderPaid", not "PayOrder".
  • It expresses business meaning, not technical details.
  • It's raised inside your domain model (usually an aggregate).

Different from other events

  • Domain Event Internal to your domain model. Expressed in ubiquitous language. Example: OrderPaid.
  • Integration Event What you publish to other systems (message bus, other services). Example: OrderPaidIntegrationEvent.
  • Application/EventBus events Internal plumbing, usually technical: UserCacheInvalidated.

We're focusing on domain events — and how they flow through Clean Architecture.

2. Where Domain Events Live in Clean Architecture

Let's assume this structure:

src/
  Shop.Domain/          # Entities, VOs, Aggregates, Domain events
  Shop.Application/     # Use cases (CQRS), DTOs, ports (interfaces)
  Shop.Infrastructure/  # EF Core, Repos, Outbox, Message bus, Email, etc.
  Shop.Api/             # Controllers / Minimal APIs

Domain events:

  • Types live in Shop.Domain
  • Raised from aggregates/entities in Domain
  • Collected and dispatched from Infrastructure (close to DbContext)
  • Handlers live in:
  • Application when they're pure domain logic / orchestration
  • Infrastructure when they talk to external systems (email, message bus, etc.)

3. Step 1 — Define a Domain Event Abstraction

In Domain project:

// Shop.Domain/Primitives/IDomainEvent.cs
namespace Shop.Domain.Primitives;
public interface IDomainEvent
{
    DateTime OccurredAtUtc { get; }
}

Example event:

// Shop.Domain/Orders/Events/OrderPaid.cs
using Shop.Domain.Primitives;
using Shop.Domain.ValueObjects;
using Shop.Domain.Orders;
namespace Shop.Domain.Orders.Events;
public sealed record OrderPaid(
    OrderId OrderId,
    Guid CustomerId,
    Money Total,
    DateTime OccurredAtUtc
) : IDomainEvent;

Event is:

  • Immutable
  • Describes what happened
  • Contains identities, not entire graphs (i.e., don't stuff the whole Order object in there).

4. Step 2 — Make Aggregates Capable of Raising Events

Common pattern: base class for entities/aggregates that can hold domain events.

// Shop.Domain/Primitives/Entity.cs
namespace Shop.Domain.Primitives;
public abstract class Entity
{
    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents;
    protected void Raise(IDomainEvent @event) => _domainEvents.Add(@event);
    public void ClearDomainEvents() => _domainEvents.Clear();
}

Now your aggregate root can inherit from Entity.

Example Order aggregate:

// Shop.Domain/Orders/Order.cs
using Shop.Domain.Primitives;
using Shop.Domain.Orders.Events;
using Shop.Domain.ValueObjects;
namespace Shop.Domain.Orders;
public sealed class Order : Entity
{
    // Fields/properties omitted for brevity…
    public OrderId Id { get; private set; } = OrderId.New();
    public Guid CustomerId { get; private set; }
    public Money Total { get; private set; } = Money.Zero("USD");
    public OrderStatus Status { get; private set; } = OrderStatus.Pending;
    public DateTime CreatedAtUtc { get; private set; } = DateTime.UtcNow;
    // For EF
    private Order() { }
    private Order(Guid customerId, string currency)
    {
        if (customerId == Guid.Empty)
            throw new ArgumentException("CustomerId required");
        CustomerId = customerId;
        Total = Money.Zero(currency);
        Status = OrderStatus.Pending;
    }
    public static Order Create(Guid customerId, string currency) =>
        new(customerId, currency);
    public void MarkAsPaid()
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException("Only pending orders can be paid.");
        if (Total.Amount <= 0)
            throw new InvalidOperationException("Cannot pay zero or negative total.");
        Status = OrderStatus.Paid;
        Raise(new OrderPaid(
            OrderId: Id,
            CustomerId: CustomerId,
            Total: Total,
            OccurredAtUtc: DateTime.UtcNow));
    }
    // other behavior: AddLine, RecalculateTotal, etc.
}

Now:

  • The domain model decides when to raise OrderPaid.
  • Nothing else has to remember to "publish an event" after paying an order.

5. Step 3 — Collect Events from EF Core's DbContext

When you call SaveChangesAsync, your DbContext is tracking updated entities. We want to:

  1. Find all entities that have domain events.
  2. Pull out those events.
  3. Dispatch them (synchronously or via outbox).
  4. Clear them from the entities.

In Infrastructure project:

// Shop.Infrastructure/Persistence/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using Shop.Domain.Primitives;
using Shop.Domain.Orders;
namespace Shop.Infrastructure.Persistence;
public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
    : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
    public async Task<int> SaveChangesWithDomainEventsAsync(
        IDomainEventDispatcher dispatcher,
        CancellationToken ct = default)
    {
        // 1. Gather domain events from tracked entities BEFORE saving
        var domainEvents = ChangeTracker.Entries<Entity>()
            .Select(e => e.Entity)
            .SelectMany(e => e.DomainEvents)
            .ToList();
        // 2. Save changes (commit transaction)
        var result = await base.SaveChangesAsync(ct);
        // 3. Dispatch events
        if (domainEvents.Count > 0)
        {
            await dispatcher.DispatchAsync(domainEvents, ct);
        }
        // 4. Clear events
        foreach (var entry in ChangeTracker.Entries<Entity>())
            entry.Entity.ClearDomainEvents();
        return result;
    }
    // If you sometimes call base.SaveChangesAsync directly, override it too:
    public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
        => await SaveChangesWithDomainEventsAsync(NullDomainEventDispatcher.Instance, ct);
}

Here we've introduced IDomainEventDispatcher – an abstraction for dispatching domain events.

6. Step 4 — Implement a Domain Event Dispatcher

Two main variants:

  • In-process, synchronous dispatcher (simple, great inside a single app).
  • Outbox-based dispatcher for reliability and cross-service messaging.

6.1 Simple In-Process Dispatcher (using MediatR)

Option A: Reuse MediatR's pub/sub model.

Define the dispatcher:

// Shop.Application/Abstractions/IDomainEventDispatcher.cs
using Shop.Domain.Primitives;
namespace Shop.Application.Abstractions;
public interface IDomainEventDispatcher
{
    Task DispatchAsync(IEnumerable<IDomainEvent> events, CancellationToken ct = default);
}

Implementation in Infrastructure:

// Shop.Infrastructure/Events/MediatRDomainEventDispatcher.cs
using MediatR;
using Shop.Application.Abstractions;
using Shop.Domain.Primitives;
namespace Shop.Infrastructure.Events;
public sealed class MediatRDomainEventDispatcher(IMediator mediator)
    : IDomainEventDispatcher
{
    public async Task DispatchAsync(IEnumerable<IDomainEvent> events, CancellationToken ct = default)
    {
        foreach (var domainEvent in events)
        {
            // Wrap domain event as INotification
            await mediator.Publish(new DomainEventNotification(domainEvent), ct);
        }
    }
}
public sealed class DomainEventNotification : INotification
{
    public IDomainEvent DomainEvent { get; }
    public DomainEventNotification(IDomainEvent domainEvent)
        => DomainEvent = domainEvent;
}

Then you write handlers for your events using MediatR's INotificationHandler<T>.

Example: handle OrderPaid to update a read model or send an internal notification:

// Shop.Application/Orders/Handlers/OrderPaidHandler.cs
using MediatR;
using Shop.Domain.Orders.Events;
namespace Shop.Application.Orders.Handlers;
public sealed class OrderPaidHandler : INotificationHandler<DomainEventNotification>
{
    private readonly ILogger<OrderPaidHandler> _log;
    public OrderPaidHandler(ILogger<OrderPaidHandler> log)
        => _log = log;
    public Task Handle(DomainEventNotification notification, CancellationToken ct)
    {
        if (notification.DomainEvent is not OrderPaid e)
            return Task.CompletedTask;
        _log.LogInformation("Order {OrderId} was paid by customer {CustomerId} for {Amount}{Currency}",
            e.OrderId.Value, e.CustomerId, e.Total.Amount, e.Total.Currency);
        // Do domain-level reactions here: update projections, etc.
        return Task.CompletedTask;
    }
}

You can also make the notification generic (DomainEventNotification<T>) if you like stronger typing.

💡 In-process dispatch is great for pure domain reactions (read models, internal stats). Once you touch external systems (email, bus), you'll want an outbox.

7. Step 5 — Reliable Delivery via Outbox Pattern

In the simple dispatcher above, if your app crashes after saving the DB but before publishing an event (or while sending an email), you will lose the event or cause inconsistency.

The Outbox pattern solves this:

Save domain events into an "outbox" table in the same transaction as your aggregate. Then a background worker reads the outbox and publishes events reliably.

7.1 Outbox Entity

In Infrastructure:

// Shop.Infrastructure/Outbox/OutboxMessage.cs
namespace Shop.Infrastructure.Outbox;
public sealed class OutboxMessage
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public DateTime OccurredAtUtc { get; init; }
    public string Type { get; init; } = default!;      // full type name of domain event
    public string Payload { get; init; } = default!;   // JSON payload
    public DateTime? ProcessedAtUtc { get; set; }
    public string? Error { get; set; }
}

Add DbSet<OutboxMessage> to AppDbContext.

public DbSet<OutboxMessage> OutboxMessages => Set<OutboxMessage>();

7.2 Save Events to Outbox on SaveChanges

Instead of directly dispatching events in SaveChangesWithDomainEventsAsync, store them:

public async Task<int> SaveChangesWithDomainEventsAsync(
    CancellationToken ct = default)
{
    var domainEvents = ChangeTracker.Entries<Entity>()
        .Select(e => e.Entity)
        .SelectMany(e => e.DomainEvents)
        .ToList();
foreach (var domainEvent in domainEvents)
    {
        OutboxMessages.Add(new OutboxMessage
        {
            OccurredAtUtc = domainEvent.OccurredAtUtc,
            Type = domainEvent.GetType().FullName!,
            Payload = JsonSerializer.Serialize(domainEvent, domainEvent.GetType())
        });
    }
    // Now saving both aggregates and outbox messages in one transaction
    var result = await base.SaveChangesAsync(ct);
    foreach (var entry in ChangeTracker.Entries<Entity>())
        entry.Entity.ClearDomainEvents();
    return result;
}

7.3 Background Worker to Process Outbox

In Infrastructure:

// Shop.Infrastructure/Outbox/OutboxProcessor.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Shop.Domain.Primitives;
using Shop.Infrastructure.Persistence;
using System.Text.Json;
namespace Shop.Infrastructure.Outbox;
public sealed class OutboxProcessor(
    IServiceProvider sp,
    ILogger<OutboxProcessor> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                using var scope = sp.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                var batch = await db.OutboxMessages
                    .Where(m => m.ProcessedAtUtc == null)
                    .OrderBy(m => m.OccurredAtUtc)
                    .Take(100)
                    .ToListAsync(ct);
                if (batch.Count == 0)
                {
                    await Task.Delay(500, ct);
                    continue;
                }
                foreach (var msg in batch)
                {
                    try
                    {
                        var domainEvent = DeserializeDomainEvent(msg);
                        await DispatchAsync(domainEvent, scope.ServiceProvider, ct);
                        msg.ProcessedAtUtc = DateTime.UtcNow;
                        msg.Error = null;
                    }
                    catch (Exception ex)
                    {
                        log.LogError(ex, "Failed to process outbox message {Id}", msg.Id);
                        msg.Error = ex.Message;
                    }
                }
                await db.SaveChangesAsync(ct);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                // shutting down
            }
            catch (Exception ex)
            {
                log.LogError(ex, "Outbox processor loop failed");
                await Task.Delay(2000, ct); // simple backoff
            }
        }
    }
    private static IDomainEvent DeserializeDomainEvent(OutboxMessage msg)
    {
        var type = Type.GetType(msg.Type)
            ?? throw new InvalidOperationException($"Unknown event type: {msg.Type}");
        var ev = (IDomainEvent?)JsonSerializer.Deserialize(msg.Payload, type);
        return ev ?? throw new InvalidOperationException("Deserialized null domain event");
    }
    private static async Task DispatchAsync(IDomainEvent domainEvent,
        IServiceProvider sp,
        CancellationToken ct)
    {
        // You can either:
        // - Use MediatR (publish); or
        // - Map to integration events (RabbitMQ, Kafka, etc.)
        var mediator = sp.GetRequiredService<IMediator>();
        await mediator.Publish(domainEvent, ct); // if domain events implement INotification too
    }
}

You can adapt this to:

  • Directly publish integration events (e.g., OrderPaidIntegrationEvent) to a message bus.
  • Or first map domain events → integration events.

Key point: no event is lost, even if the process crashes mid-flight.

8. Wiring Everything Up in DI

In your Api/host project:

// Program.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Shop.Application.Abstractions;
using Shop.Infrastructure.Events;
using Shop.Infrastructure.Outbox;
using Shop.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt =>
{
    opt.UseSqlServer(builder.Configuration.GetConnectionString("Default"));
});
builder.Services.AddMediatR(cfg =>
{
    // Register Application handlers and domain event handlers
    cfg.RegisterServicesFromAssembly(typeof(SomeApplicationType).Assembly);
});
builder.Services.AddScoped<IDomainEventDispatcher, MediatRDomainEventDispatcher>();
builder.Services.AddHostedService<OutboxProcessor>(); // if using outbox
var app = builder.Build();
// ...
app.Run();

Then anywhere you were doing:

await db.SaveChangesAsync(ct);

…switch to:

await db.SaveChangesWithDomainEventsAsync(ct);

or, if using the outbox-only variant, just SaveChangesAsync since it writes to Outbox inside it.

9. Domain Events vs Application Events vs Integration Events (Clear Boundaries)

Quick mental model:

  • Domain Event — inside the domain model
  • Lives in Domain project
  • Raised by aggregates
  • Talks in business language: OrderPaid, CustomerRegistered
  • Application Event — inside the application boundary
  • Could be the same type as domain event (reused)
  • Or separate, more technical event types
  • Integration Event — leaving the boundary
  • Published on message bus, external subscribers see it
  • Might hide internal details and use a "contract-friendly" payload

Often:

  1. Aggregate raises OrderPaid (Domain Event).
  2. Outbox stores OrderPaid.
  3. Outbox worker maps OrderPaidOrderPaidIntegrationEvent and publishes to message bus.

This keeps your domain model decoupled from other services.

10. Common Pitfalls (and How to Avoid Them)

❌ Doing remote calls inside the transaction

If your domain event handler sends email or calls another service inside the SaveChanges transaction, you risk:

  • Long-running transactions
  • Deadlocks
  • "Sent twice" vs "never saved" scenarios

✅ Better:

  • Use the outbox worker to do external communication after commit.
  • Keep in-transaction handlers fast and side-effect-free (update read models, internal counters, etc.)

❌ Stuffing everything into a single God event

public sealed record DataChanged(object Everything);

Not helpful.

✅ Use specific, meaningful events per business outcome:

  • OrderPaid
  • OrderCancelled
  • StockLevelChanged
  • CustomerUpgradedToPremium

❌ Leaking Infrastructure concerns into Domain events

Domain event should not say: EmailSent or WebhookQueued.

Those are technical. Domain is about business.

✅ Domain event: InvoiceOverdue. Infrastructure decides to send email/webhook/etc. in reaction.

❌ Not making handlers idempotent

Outbox + retries can cause handlers to run more than once.

✅ Design handlers so they can handle duplicates:

  • Check if work was already done (e.g., integration event already published, email already sent).
  • Use unique keys / deduplication tables when needed.

11. Quick Checklist (Pin This Somewhere)

  • Domain events (IDomainEvent) live in Domain project.
  • Aggregates/entities inherit from a base Entity with DomainEvents collection.
  • Aggregates raise events in behavior methods (e.g., MarkAsPaid).
  • EF Core DbContext collects domain events from tracked entities.
  • Domain events get:
  • Dispatched in-process via dispatcher (MediatR or custom), and/or
  • Stored in an Outbox table in the same transaction.
  • Background worker reads Outbox:
  • Deserializes events
  • Publishes them (via MediatR and/or message bus)
  • Marks messages as processed, handles errors
  • External side-effects (email, message bus, HTTP calls) happen after commit, not in the main transaction.
  • Handlers are idempotent and fast.
  • Domain events use business language (past tense, meaningful).

Final Thoughts

The Domain Event pattern is how you teach your system to react instead of just execute commands:

  • Your aggregates raise events when something meaningful happens.
  • Your application and infrastructure react in a decoupled way.
  • Your consistency story improves with the outbox.
  • Your architecture quietly becomes more modular and scalable.

And best of all: it's just C# classes and a bit of plumbing. Works the same in .NET 8, 9, and the future .NET 10.