Mert Özen Further With Every Line
Software Architecture Domain-Driven Design — #12

Domain Events: Raise, Collect, and Dispatch on SaveChanges

Mert Özen Nov 7, 2026 16 min 16 views
Domain Events: Raise, Collect, and Dispatch on SaveChanges

An aggregate records what happened as a past-tense, immutable domain event in its own list; EF Core 10's SaveChangesInterceptor collects the events during SaveChanges, runs same-transaction handlers before the commit and side effects that leave the database after it. A mediator-free dispatcher, the nested SaveChanges and explicit transaction traps with real output, and how integration events and the outbox differ.

Version note: The code in this post was built and run with C# 14, .NET 10, and Entity Framework Core (EF Core) 10.

Guess First

In the previous post we moved the shipping rule into Order and a domain service. The cancellation side still looks like this: when a customer cancels, the service releases the stock, sends the email, then saves. The nightly job that cancels orders whose payment failed calls the same Order.Cancel.

using Bookstore.Application;
using Bookstore.Infrastructure;
using Bookstore.Inventory;
using Bookstore.Ordering;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Before;

public sealed class CancelOrderService(BookstoreDbContext db, IEmailSender email, TimeProvider clock)
{
    public async Task CancelAsync(OrderId id, CancellationToken ct)
    {
        Order order = await db.Orders.SingleAsync(o => o.Id == id, ct);
        order.Cancel(CancellationReason.CustomerRequest, clock);

        foreach (OrderItem item in order.Items)
        {
            StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == item.BookId, ct);
            stock.Release(item.Quantity);
        }

        await email.SendAsync("Your order was cancelled", ct);
        await db.SaveChangesAsync(ct);
    }
}

// Nightly job that cancels orders whose payment failed
public sealed class PaymentFailedJob(BookstoreDbContext db, TimeProvider clock)
{
    public async Task RunAsync(IReadOnlyList<OrderId> failed, CancellationToken ct)
    {
        foreach (OrderId id in failed)
        {
            Order order = await db.Orders.SingleAsync(o => o.Id == id, ct);
            order.Cancel(CancellationReason.PaymentFailed, clock);
        }
        await db.SaveChangesAsync(ct);
    }
}

Two questions. When the nightly job cancels 40 orders, what happens to the stock of the books in them? On a customer cancellation, if SaveChangesAsync throws a concurrency error after the email has gone out, what does the customer believe and what does the database show?

What Is a Domain Event?

The answer to the first question is visible once you read the code: the job never releases the stock. The rule "when an order is cancelled, the reserved copies are released" doesn't live in Order but in the body of CancelOrderService; every new entry point that cancels has to remember it, and the job didn't. The books from those 40 orders stay unsellable while sitting in the warehouse. The answer to the second is in the ordering: the email was sent, the save failed. The customer has an email saying "cancelled" while the database still shows the order as Placed.

Both bugs share a root: the fact that "something happened" has no name in the model. A domain event gives it one: something that happened in the domain in the past and that the domain expert cares about. Eric Evans didn't describe this building block in his book (2003); he added it in his 2009 talk on what he had learned since the book, and in the DDD (Domain-Driven Design) Reference published in 2015 it appears, together with Partnership and Big Ball of Mud, as one of three patterns introduced after the book. The orange stickies on the EventStorming wall in post 06 of this series were exactly this: OrderPlaced, OrderCancelled, StockReserved.

There are three rules when putting them into code. The name is in the past tense: OrderCancelled is a fact, CancelOrder is a command. The event is immutable: something that happened doesn't change later, hence a sealed record with init-only properties. The content is sufficient but small: ids, quantities, the reason, and the time. Microsoft's eShop sample puts the Order entity itself inside the event; that's practical within one process but hands the handler the whole aggregate and the ability to change it. Here we carry only ids and values.

Raise and Collect: An Event List on the Aggregate

The first way to publish an event was the static DomainEvents.Raise(...) call Udi Dahan proposed in 2008: the entity raises the event and the handlers run right then. In his 2014 post "A better domain events pattern", Jimmy Bogard proposed deferring instead: the entity only appends the event to its own list, and dispatching happens at save time. This is the approach that's common in the .NET world today, and the one the eShop guide on Microsoft Learn describes:

namespace Bookstore.BuildingBlocks;

// Domain event: something that happened in the past that the domain expert cares about
public interface IDomainEvent
{
    DateTimeOffset OccurredAt { get; }
}

public abstract class AggregateRoot
{
    private readonly List<IDomainEvent> _domainEvents = [];

    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents;

    // Raise only appends to the list; nobody gets called
    protected void Raise(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);

    public void ClearDomainEvents() => _domainEvents.Clear();
}
using Bookstore.BuildingBlocks;
using Bookstore.SharedKernel;

namespace Bookstore.Ordering;

public readonly record struct OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.CreateVersion7());
}

public readonly record struct BookId(Guid Value);
public readonly record struct CustomerId(Guid Value);

public sealed class DomainException(string code, string message) : Exception(message)
{
    public string Code { get; } = code;
}

public enum OrderStatus { Placed, PaymentPending, Paid, Shipped, Delivered, Cancelled }
public enum CancellationReason { CustomerRequest, PaymentFailed, OutOfStock }

public sealed record OrderedBook(BookId BookId, int Quantity);

public sealed record OrderPlaced(
    OrderId OrderId, CustomerId CustomerId, IReadOnlyList<OrderedBook> Books, Money Total, DateTimeOffset OccurredAt)
    : IDomainEvent;

public sealed record OrderCancelled(
    OrderId OrderId, IReadOnlyList<OrderedBook> Books, CancellationReason Reason, DateTimeOffset OccurredAt)
    : IDomainEvent;

public sealed class OrderItem
{
    private OrderItem() { } // EF Core

    public OrderItem(BookId bookId, string title, Money unitPrice, int quantity) =>
        (BookId, Title, UnitPrice, Quantity) = (bookId, title, unitPrice, quantity);

    public BookId BookId { get; private set; }
    public string Title { get; private set; } = "";
    public Money UnitPrice { get; private set; }
    public int Quantity { get; private set; }
}

public sealed class Order : AggregateRoot
{
    private readonly List<OrderItem> _items = [];

    private Order() { } // EF Core

    public OrderId Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public IReadOnlyList<OrderItem> Items => _items;
    public Money Total => _items.Aggregate(Money.Zero(_items[0].UnitPrice.Currency), (sum, i) => sum + i.UnitPrice * i.Quantity);

    public static Order Place(CustomerId customerId, IEnumerable<OrderItem> items, TimeProvider clock)
    {
        Order order = new() { Id = OrderId.New(), CustomerId = customerId, Status = OrderStatus.Placed };
        order._items.AddRange(items);
        if (order._items.Count == 0)
            throw new DomainException("order.empty", "An order needs at least one item.");

        order.Raise(new OrderPlaced(order.Id, customerId, order.Books(), order.Total, clock.GetUtcNow()));
        return order;
    }

    public void Cancel(CancellationReason reason, TimeProvider clock)
    {
        if (Status is OrderStatus.Cancelled)
            throw new DomainException("order.cancelled", $"Order {Id.Value} is already cancelled.");
        if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
            throw new DomainException("order.already-shipped", $"Order is {Status}; request a return instead.");

        Status = OrderStatus.Cancelled;
        Raise(new OrderCancelled(Id, Books(), reason, clock.GetUtcNow()));
    }

    private List<OrderedBook> Books() => [.. _items.Select(i => new OrderedBook(i.BookId, i.Quantity))];
}

AggregateRoot and IDomainEvent aren't domain concepts but technical building blocks. In the context map from post 05 of the series, the Shared Kernel contained only Money; we didn't put these two types there but in a small Bookstore.BuildingBlocks namespace. As long as no business rule gets into it, it won't turn into a "Common.dll" dumping ground. Order.Cancel now knows nothing about stock or email. It changes its own state and records what happened; on an order that is already cancelled or shipped it throws a DomainException with a code, as in post 08, so the same fact can't be published twice. The event's time comes from TimeProvider so it can be fixed in tests. Raising events needs no database; testing the aggregate is as simple as looking at the list:

Order draft = Order.Place(ada, [new OrderItem(dune, "Dune", new Money(30m, "EUR"), 2)], clock);
Console.WriteLine($"Raised without a database: {string.Join(", ", draft.DomainEvents.Select(e => e.GetType().Name))}");
Raised without a database: OrderPlaced

A Dispatcher Without a Mediator

Something has to deliver the list to the handlers. eShop does it with MediatR; here two interfaces and an abstract class are enough:

using Bookstore.BuildingBlocks;
using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Application;

public enum DispatchPhase { BeforeCommit, AfterCommit }

public interface IDomainEventHandler
{
    DispatchPhase Phase { get; }
    bool CanHandle(IDomainEvent domainEvent);
    Task HandleAsync(IDomainEvent domainEvent, CancellationToken ct);
}

// Generic base: no reflection, each handler states its own event type
public abstract class DomainEventHandler<TEvent> : IDomainEventHandler where TEvent : IDomainEvent
{
    public virtual DispatchPhase Phase => DispatchPhase.BeforeCommit;

    public bool CanHandle(IDomainEvent domainEvent) => domainEvent is TEvent;

    Task IDomainEventHandler.HandleAsync(IDomainEvent domainEvent, CancellationToken ct) =>
        HandleAsync((TEvent)domainEvent, ct);

    protected abstract Task HandleAsync(TEvent domainEvent, CancellationToken ct);
}

public sealed class DomainEventDispatcher(IServiceProvider services)
{
    public async Task DispatchAsync(IReadOnlyList<IDomainEvent> events, DispatchPhase phase, CancellationToken ct)
    {
        IDomainEventHandler[] handlers = [.. services.GetServices<IDomainEventHandler>().Where(h => h.Phase == phase)];
        foreach (IDomainEvent domainEvent in events)
        {
            foreach (IDomainEventHandler handler in handlers.Where(h => h.CanHandle(domainEvent)))
                await handler.HandleAsync(domainEvent, ct);
        }
    }
}

Every handler derives from DomainEventHandler<TEvent> and states the phase it runs in through Phase. The dispatcher takes all IDomainEventHandlers from the DI (dependency injection) container and calls the ones matching the event's type in order; no MakeGenericType, no Invoke. In an application with tens of handlers this is fast and readable enough; with hundreds you'd add a per-type cache. If you want MediatR, the same shape is built with INotification and INotificationHandler<T>; we discussed when you actually need it in the Design Patterns series post "Mediator Pattern".

Before SaveChanges: An Interceptor and a Single Transaction

You could call the dispatcher by hand in the application service before SaveChangesAsync, but then the opening problem comes back: someone forgets to call it. It's safer to get inside SaveChangesAsync, the single door for saving. EF Core's SaveChangesInterceptor exists for this; its general use is covered in the EF Core series post "EF Core Interceptors and Events: Audit Fields and Slow Query Logging".

12-domain-event-flow-en.png

using Bookstore.Application;
using Bookstore.BuildingBlocks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Bookstore.Infrastructure;

// It holds state, so one instance per DbContext (scoped)
public sealed class DomainEventsInterceptor(DomainEventDispatcher dispatcher) : SaveChangesInterceptor
{
    private const int MaxRounds = 5;
    private readonly List<IDomainEvent> _dispatched = [];

    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct = default)
    {
        DbContext context = eventData.Context!;
        _dispatched.Clear(); // events left over from an earlier failed attempt must not leak into "after"
        // Handlers can raise new events; keep going round until the list is empty
        for (int round = 1; ; round++)
        {
            List<IDomainEvent> events = CollectAndClear(context);
            if (events.Count == 0)
                return result;
            if (round > MaxRounds)
                throw new InvalidOperationException($"Domain events did not settle after {MaxRounds} rounds.");

            await dispatcher.DispatchAsync(events, DispatchPhase.BeforeCommit, ct);
            _dispatched.AddRange(events);
        }
    }

    public override async ValueTask<int> SavedChangesAsync(
        SaveChangesCompletedEventData eventData, int result, CancellationToken ct = default)
    {
        IDomainEvent[] committed = [.. _dispatched];
        _dispatched.Clear();
        await dispatcher.DispatchAsync(committed, DispatchPhase.AfterCommit, ct);
        return result;
    }

    public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result) =>
        throw new NotSupportedException("Use SaveChangesAsync; domain event handlers are async.");

    private static List<IDomainEvent> CollectAndClear(DbContext context)
    {
        AggregateRoot[] aggregates = [.. context.ChangeTracker.Entries<AggregateRoot>()
            .Select(e => e.Entity)
            .Where(a => a.DomainEvents.Count > 0)];

        List<IDomainEvent> events = [.. aggregates.SelectMany(a => a.DomainEvents)];
        foreach (AggregateRoot aggregate in aggregates)
            aggregate.ClearDomainEvents();
        return events;
    }
}

An important detail is in EF Core's source code: DbContext.SaveChangesAsync first calls the interceptors' SavingChangesAsync, and only after that runs DetectChanges and generates the SQL (Structured Query Language). So the changes the handlers make on the same DbContext are included in that save; no separate SaveChanges is needed. That's also why the loop exists: StockItem.TryReserve raises its own StockReserved event, and it gets dispatched in the second round. Stopping after five rounds is insurance against two handlers triggering each other forever.

The interceptor holds state (the list of dispatched events), so, as in the audit example in the EF Core documentation, each DbContext instance needs its own instance. The registration takes the scoped interceptor from the same scope; since the handlers are resolved from the same scope too, they all see the same DbContext:

ServiceCollection services = new();
services.AddSingleton(TimeProvider.System);
services.AddSingleton<ConsoleEmailSender>();
services.AddSingleton<IEmailSender>(sp => sp.GetRequiredService<ConsoleEmailSender>());
services.AddScoped<DomainEventDispatcher>();
services.AddScoped<DomainEventsInterceptor>();
services.AddScoped<IDomainEventHandler, ReserveStockWhenOrderPlaced>();
services.AddScoped<IDomainEventHandler, ReleaseStockWhenOrderCancelled>();
services.AddScoped<IDomainEventHandler, SendConfirmationWhenOrderPlaced>();
services.AddDbContext<BookstoreDbContext>((sp, options) => options
    .UseSqlite(connection)
    .AddInterceptors(sp.GetRequiredService<DomainEventsInterceptor>())
    .LogTo(m => Console.WriteLine($"  [db] {m}"),
        [RelationalEventId.TransactionCommitted, RelationalEventId.TransactionRolledBack],
        LogLevel.Debug, DbContextLoggerOptions.None));

The handler that reserves stock and the one that sends the email after the commit. Following the decision in post 08, StockItem.TryReserve returns false rather than throwing when there isn't enough stock; it's the handler in the application layer that turns that false into a DomainException that stops the save:

using Bookstore.Infrastructure;
using Bookstore.Inventory;
using Bookstore.Ordering;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Application;

// Same transaction: the StockItems are saved together with the Order, or nothing is
public sealed class ReserveStockWhenOrderPlaced(BookstoreDbContext db, TimeProvider clock)
    : DomainEventHandler<OrderPlaced>
{
    protected override async Task HandleAsync(OrderPlaced e, CancellationToken ct)
    {
        foreach (OrderedBook book in e.Books)
        {
            StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == book.BookId, ct);
            if (!stock.TryReserve(e.OrderId, book.Quantity, clock))
                throw new DomainException("order.out-of-stock", $"Only {stock.Available} of '{stock.Title}' available.");
            Console.WriteLine($"  [before commit] reserved {book.Quantity} x {stock.Title} ({stock.Available} left)");
        }
    }
}

public sealed class ReleaseStockWhenOrderCancelled(BookstoreDbContext db) : DomainEventHandler<OrderCancelled>
{
    protected override async Task HandleAsync(OrderCancelled e, CancellationToken ct)
    {
        foreach (OrderedBook book in e.Books)
        {
            StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == book.BookId, ct);
            stock.Release(book.Quantity);
            Console.WriteLine($"  [before commit] released {book.Quantity} x {stock.Title} ({stock.Available} left)");
        }
    }
}

// After the commit: a side effect that leaves the database
public sealed class SendConfirmationWhenOrderPlaced(IEmailSender email) : DomainEventHandler<OrderPlaced>
{
    public override DispatchPhase Phase => DispatchPhase.AfterCommit;

    protected override Task HandleAsync(OrderPlaced e, CancellationToken ct) =>
        email.SendAsync($"Order confirmed, total {e.Total}", ct);
}

public interface IEmailSender
{
    Task SendAsync(string subject, CancellationToken ct);
}

public sealed class ConsoleEmailSender : IEmailSender
{
    public int Sent { get; private set; }

    public Task SendAsync(string subject, CancellationToken ct)
    {
        Sent++;
        Console.WriteLine($"  [after commit] email: {subject}");
        return Task.CompletedTask;
    }
}

In this short example the handlers use BookstoreDbContext directly; in a Clean Architecture layout the same job is done by a repository or an interface in Application (post 05 of the Clean, Onion, and Hexagonal Architecture series). Three scenarios: Ada orders two copies of Dune; then she asks for two copies of Emma, of which only one is in stock; then she cancels her first order.

Console.WriteLine("1) Place 2 x Dune");
await InScope(async db =>
{
    Order order = Order.Place(ada, [new OrderItem(dune, "Dune", new Money(30m, "EUR"), 2)], clock);
    db.Orders.Add(order);
    await db.SaveChangesAsync();
    placedId = order.Id;
});
Console.WriteLine("2) Place 1 x Dune + 2 x Emma");
await InScope(async db =>
{
    Order order = Order.Place(ada,
        [new OrderItem(dune, "Dune", new Money(30m, "EUR"), 1), new OrderItem(emma, "Emma", new Money(20m, "EUR"), 2)], clock);
    db.Orders.Add(order);
    try
    {
        await db.SaveChangesAsync();
    }
    catch (DomainException ex)
    {
        Console.WriteLine($"  SaveChanges failed: {ex.Message}");
    }
});
await InScope(async db =>
{
    StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == dune);
    Console.WriteLine($"  Orders: {await db.Orders.CountAsync()}, Dune available: {stock.Available}");
});
Console.WriteLine("3) Cancel the first order");
await InScope(async db =>
{
    Order order = await db.Orders.SingleAsync(o => o.Id == placedId);
    order.Cancel(CancellationReason.CustomerRequest, clock);
    await db.SaveChangesAsync();
});
1) Place 2 x Dune
  [before commit] reserved 2 x Dune (1 left)
  [db] Committed transaction.
  [after commit] email: Order confirmed, total 60.00 EUR
2) Place 1 x Dune + 2 x Emma
  [before commit] reserved 1 x Dune (0 left)
  SaveChanges failed: Only 1 of 'Emma' available.
  Orders: 1, Dune available: 1
3) Cancel the first order
  [before commit] released 2 x Dune (3 left)
  [db] Committed transaction.

In the first scenario the order matches the figure: stock reserved, transaction committed, email sent afterwards. In the second, Dune was reserved, TryReserve returned false for Emma, the handler threw, and nothing was written: the order count stayed at 1 and Dune's available stock at 1. The Dune copy that looked reserved in memory never reached the database. In the third, the cancellation released the stock without any service remembering to do it; since the nightly job calls the same Cancel, the opening bug closed on its own. There's no email for the cancellation because we didn't register an AfterCommit handler listening to OrderCancelled; adding one is a single registration line.

After SaveChanges: Eventual Consistency

Putting the stock reservation in the same transaction as the order is a choice, and it has a price. According to the rule in Vaughn Vernon's Effective Aggregate Design (2011) essays, a transaction should change a single aggregate, and rules across aggregates should be satisfied with eventual consistency; in post 09 of this series we drew the aggregate boundary according to that rule. Here Order and StockItem change in the same transaction. The pragmatic view in Bogard's post is that a single transaction is acceptable when the side effects of the same command live in the same database. Microsoft's eShop guide also picks this path as "the simplest". Bookstore today is a single-database monolith taking 800 orders a day; if concurrent orders for the same book start colliding on the StockItem row at campaign peak, the reservation will have to move after the commit, closing the order with a compensating cancellation on OutOfStock. The decision isn't technical but one to make with the domain expert: "if there's no stock, the order should never exist" or "let it exist, then cancel it"?

Every side effect that leaves the database, on the other hand, should run after the commit. If you send the email in SavingChangesAsync you can't take it back when the save fails; that's the second bug from the opening. But "after the commit" has a footnote. SavedChangesAsync runs when SaveChangesAsync finishes; if the calling code opened its own transaction, that isn't a commit yet:

Console.WriteLine("4) Explicit transaction, then rollback");
await InScope(async db =>
{
    await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
    db.Orders.Add(Order.Place(ada, [new OrderItem(emma, "Emma", new Money(20m, "EUR"), 1)], clock));
    await db.SaveChangesAsync();
    await tx.RollbackAsync();
    Console.WriteLine($"  Orders after rollback: {await db.Orders.CountAsync()}");
});
4) Explicit transaction, then rollback
  [before commit] reserved 1 x Emma (0 left)
  [after commit] email: Order confirmed, total 20.00 EUR
  [db] Rolled back transaction.
  Orders after rollback: 1
Emails sent: 2

The email went out, the transaction was rolled back, there's no order. The interceptor's "after the commit" assumption holds when SaveChangesAsync opens the transaction itself. If the application has places that use explicit transactions, AfterCommit dispatching has to move to whatever manages the transaction (the application service or a decorator).

Even when it runs at the right moment, an AfterCommit handler leaves a gap: if the process crashes between the commit and the email, the email never goes out, because the event lived only in memory. The way to close it is to write the message to be sent into a table in the same transaction as the business data and publish it from a separate process, which is the outbox pattern. The outbox and reliable publishing are the subject of the upcoming Messaging and Event-Driven Architecture series; the interceptor here will be the natural entry point for the outbox in that series.

A Domain Event Is Not an Integration Event

Both say "something happened", but to different readers:

Domain eventIntegration event
ReaderSame bounded context, same processOther bounded contexts, other services
TimingSynchronous or asynchronous, usually within the same requestAlways asynchronous
When publishedCan be before the commitOnly after the commit
SchemaChanges freely with the modelA contract; versioned
TransportIn-memory dispatcherMessage broker, outbox

In the context map from post 05 of this series, Ordering published OrderPlaced to Inventory as a Published Language. That publication is an integration event: not the OrderPlaced record from Ordering's internal model, but a versioned contract derived from it and agreed on with the Inventory team. The typical flow is an AfterCommit handler (or, more safely, a BeforeCommit handler writing to the outbox) translating the domain event into an integration event. Exposing the domain event as is turns every rename in the internal model into a broken build for other teams.

Common Pitfalls

A handler calling its own SaveChanges

Whoever writes the handler adds a single line to "save the change":

foreach (OrderedBook book in e.Books)
{
    StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == book.BookId, ct);
    if (!stock.TryReserve(e.OrderId, book.Quantity, clock))
        throw new DomainException("order.out-of-stock", $"Only {stock.Available} of '{stock.Title}' available.");
    await db.SaveChangesAsync(ct); // Trap: the handler calls its own SaveChanges
2) Place 1 x Dune + 2 x Emma
  [db] Committed transaction.
  [before commit] reserved 1 x Dune (0 left)
  SaveChanges failed: Only 1 of 'Emma' available.
  Orders: 2, Dune available: 0

The order was rejected for lack of stock, but the database has two orders and zero Dune. The inner SaveChangesAsync committed the still half-built order and the Dune reservation in its own transaction in the middle of the outer save; the failure on Emma couldn't undo it. The single-transaction promise was broken by one line. The rule: BeforeCommit handlers only change things, they don't save.

Relying on SaveChangesFailed

An exception thrown from a handler doesn't trigger SaveChangesFailedAsync; I tried it and it wasn't called. In EF Core's source, the SavingChanges phase sits outside the block that catches errors. According to the documentation, DbUpdateConcurrencyException doesn't call this method either but ThrowingConcurrencyException instead. That's why the interceptor does its cleanup not on the failure path but at the start of every SavingChangesAsync.

Dispatching straight from the entity

With a static DomainEvents.Raise, handlers run in the middle of the entity method: other aggregates change while the aggregate's invariants aren't complete yet, and calling the entity in a unit test sends an email. With deferred dispatch the entity only appends to the list; the infrastructure decides when and in which transaction to dispatch.

A command dressed up as an event

An event named SendCancellationEmail or ReleaseStock is really a command: it has a single receiver and an expected outcome. The event's name describes what happened (OrderCancelled); the handlers decide who reacts and how. Test an event name as a fact that could appear in a sentence from the domain expert.

DbContext pooling and synchronous SaveChanges

With AddDbContextPool, the options are built once and shared by every instance in the pool; this interceptor, which keeps state per DbContext, doesn't fit that registration. Synchronous SaveChanges can't call async handlers either; that's why the interceptor closes the synchronous path with a NotSupportedException, as the audit example in the EF Core documentation suggests.

When Not to Use It

For a rule that stays inside a single aggregate, an event is a needless detour: "the last item can't be removed" should stay in Order's own method. If a fact has a single reaction and that reaction is always needed in the same use case, calling it directly in the application service can be more readable; the value of events shows up when the number of reactions is open-ended and the code producing the fact from different entry points (checkout, job, admin screen) shouldn't know about the reactions. The flow also gets harder to follow: someone reading order.Cancel() has to search the handlers to see where the stock is released. That's why handler names should state the fact and the reaction together: ReleaseStockWhenOrderCancelled.

A Small Experiment

★☆☆ Add an AfterCommit handler for OrderCancelled and see in the output that, on a customer cancellation, the email goes out only after the commit. Which lines of the opening CancelOrderService can be deleted?

★★☆ Write a handler that listens to StockReserved and raises an OutOfStock event when a book's available stock drops to 0. How many rounds does the interceptor go through? Measure it with a counter.

★★★ Move the stock reservation after the commit: have ReserveStockWhenOrderPlaced run AfterCommit with a new scope and a new DbContext, and cancel the order with CancellationReason.OutOfStock if there isn't enough stock. How does the output of the second scenario change, and when does the customer get which email?

We now have every piece of tactical DDD: entity, value object, aggregate, domain service, and domain event. In the next post we'll use them all together: we'll move Bookstore's Ordering context from CRUD (Create, Read, Update, Delete) code with public setters to a rich model, keeping the tests green at every step.