Chapter 12 · Saving Data: Insert, Update, Delete, Batching, ExecuteUpdate, and ExecuteDelete

Interceptors and SaveChanges Overrides for Auditing, Timestamps, Domain Events, and Outbox Hooks

Use SaveChanges overrides and ISaveChangesInterceptor deliberately for audit metadata and outbox hooks without performing unsafe external side effects before database commit.

Advanced140–180 minutesinterceptor + transactional outbox labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

Learning outcomes

ServiceHub now needs consistent audit timestamps and eventually reliable publication of domain events. Both are cross-cutting concerns around SaveChanges, but they have different durability requirements. A timestamp can be mutated before commands are generated; an email/Kafka/HTTP side effect must not be fired before the database commit and then assumed atomic. This lesson compares overrides, ISaveChangesInterceptor, and a transactional outbox hook.

01

Compare DbContext SaveChanges overrides with ISaveChangesInterceptor by scope and testability.

02

Set shadow audit metadata before save without hiding business authorization or domain rules.

03

Observe SavingChanges/SavedChanges/SaveChangesFailed events and avoid using interception as ordinary logging.

04

Explain why external side effects before commit create dual-write failures and retry duplicates.

05

Persist an outbox record in the same database transaction as aggregate changes, then dispatch separately and idempotently.

06

Design tests that prove ordering, failure behavior, and retry/idempotency instead of only checking happy-path rows.

Reproducible baseline

Mandatory labs use .NET SDK 10.0.400, .NET runtime 10.0.11, Microsoft.EntityFrameworkCore/SQLite 10.0.11, dotnet-ef 10.0.11, the disposable servicehub-lab.db, deterministic ServiceHub seed data, and no paid tooling. SQL Server/PostgreSQL native-loader examples are optional provider comparisons. EF Core 11 previews are excluded from the mandatory path.

1. Override or interceptor?

Mechanism Strength Risk
DbContext.SaveChanges* override Context-local, simple access to tracker, obvious in the context type. Can grow into a monolith and must cover sync/async consistently.
ISaveChangesInterceptor / SaveChangesInterceptor Composable per-context service; can observe/suppress/modify save flow. Ordering/statefulness/DI lifetime require care; not a replacement for application logging.
Database trigger Runs regardless of application path. Provider-specific, hidden from EF domain layer, migration/operational ownership required.

Choose the boundary matching ownership. A ServiceHub-specific invariant may belong in the domain/context; reusable audit instrumentation may fit an interceptor. Database-enforced invariants still belong in constraints/triggers where appropriate.

csharp · context-local override alternative
public override Task<int> SaveChangesAsync(    bool acceptAllChangesOnSuccess,    CancellationToken cancellationToken = default){    StampAuditMetadata();    return base.SaveChangesAsync(        acceptAllChangesOnSuccess,        cancellationToken);}

An override is appropriate when this behavior belongs specifically to ServiceHubContext. If both sync and async save APIs are exposed, keep their behavior consistent rather than accidentally stamping only one path.

2. Auditing with an interceptor: update metadata before commands are generated

csharp · shadow timestamp interceptor
public sealed class AuditSaveChangesInterceptor(TimeProvider clock)    : SaveChangesInterceptor{    public override InterceptionResult<int> SavingChanges(        DbContextEventData eventData,        InterceptionResult<int> result)    {        Stamp(eventData.Context);        return result;    }    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(        DbContextEventData eventData,        InterceptionResult<int> result,        CancellationToken cancellationToken = default)    {        Stamp(eventData.Context);        return ValueTask.FromResult(result);    }    private void Stamp(DbContext? context)    {        if (context is null) return;        var now = clock.GetUtcNow().UtcDateTime;        foreach (var entry in context.ChangeTracker.Entries<WorkOrder>())        {            if (entry.State == EntityState.Added)                entry.Property("CreatedUtc").CurrentValue = now;        }    }}

This uses the existing Chapter 04 shadow CreatedUtc; no schema change is required. It does not authorize the write or advance the business Revision token automatically—those remain explicit domain/application responsibilities.

csharp · registration is part of context configuration
services.AddSingleton(TimeProvider.System);services.AddSingleton<AuditSaveChangesInterceptor>();services.AddDbContext<ServiceHubContext>((sp, options) =>    options        .UseSqlite(connectionString)        .AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>()));

Interceptors are registered with context configuration. Keep stateful interceptor lifetime/thread-safety aligned with how contexts are created; a stateless singleton using thread-safe dependencies is different from an interceptor that accumulates per-save mutable state.

3. Deliberately wrong: publish external events inside SavingChanges

csharp · unsafe dual write
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(...){    await messageBus.PublishAsync(new WorkOrderChanged(...), cancellationToken);    return result;}

If the message succeeds and the database transaction later fails, consumers observe an event for data that never committed. If the database commits and the process crashes before publication, the event is lost. Retries can publish duplicates. An interceptor cannot turn two independent durable systems into one atomic transaction by wishful ordering.

External side effects are not pre-commit hooks

Do not send email, HTTP calls, Kafka/Rabbit messages, or cloud events from SavingChanges and assume the side effect is transactionally coupled to the database.

4. Transactional outbox: make the database write atomic, dispatch later

The outbox pattern stores an event/message row in the same database transaction as the aggregate change. A separate dispatcher later publishes undelivered rows and marks them processed using an idempotent protocol. The outbox does not create exactly-once delivery; it creates a durable handoff that can be retried.

csharp · teaching entity for the disposable outbox lab
public sealed class OutboxMessage{    public Guid Id { get; init; }    public DateTime OccurredUtc { get; init; }    public string Type { get; init; } = null!;    public string PayloadJson { get; init; } = null!;    public DateTime? ProcessedUtc { get; set; }}
csharp · capture messages before the same SaveChanges transaction executes
foreach (var order in db.ChangeTracker.Entries<WorkOrder>()         .Where(e => e.State == EntityState.Modified)){    db.Set<OutboxMessage>().Add(new OutboxMessage    {        Id = Guid.NewGuid(),        OccurredUtc = clock.GetUtcNow().UtcDateTime,        Type = "WorkOrderChanged",        PayloadJson = BuildMinimalOutboxPayload(order.Entity)    });}await db.SaveChangesAsync(ct); // aggregate + outbox rows share this SaveChanges transaction

For the hands-on lab, add an outbox_messages table in the disposable SQLite database via a reviewed migration such as AddOutboxLab. This is an explicit Chapter 12 lab evolution; do not hide it inside EnsureCreated.

5. Dispatch after commit with idempotency and observable state

csharp · separate dispatcher outline
var pending = await db.Set<OutboxMessage>()    .Where(x => x.ProcessedUtc == null)    .OrderBy(x => x.OccurredUtc)    .Take(100)    .ToListAsync(ct);foreach (var message in pending){    await publisher.PublishAsync(        message.Type,        message.PayloadJson,        idempotencyKey: message.Id.ToString(),        ct);    message.ProcessedUtc = clock.GetUtcNow().UtcDateTime;    await db.SaveChangesAsync(ct);}

A crash between publish and ProcessedUtc can cause redelivery, so the message ID must serve as an idempotency/deduplication key where the downstream contract allows it. Chapter 25 revisits production outbox architecture; here the goal is write-order correctness.

6. SavedChanges and SaveChangesFailed are observability/control hooks, not magic repair

csharp · observe outcome without hiding it
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result){    logger.LogInformation("SaveChanges committed {Rows} state entries", result);    return result;}public override void SaveChangesFailed(DbContextErrorEventData eventData){    logger.LogError(eventData.Exception, "SaveChanges failed for {ContextId}",        eventData.Context?.ContextId);}

Prefer Microsoft.Extensions.Logging/diagnostics for ordinary logs; interception is justified when the application must participate in or observe the save operation itself. Do not swallow an exception in the interceptor unless you are deliberately changing the operation contract and have exhaustive tests.

7. Test the ordering and failure cases

  1. Reset SQLite and verify CreatedUtc is stamped for an Added work order by the interceptor.
  2. Use a fake TimeProvider so timestamp assertions are deterministic.
  3. Trigger a database constraint failure and verify SaveChangesFailed is observed while the database remains unchanged.
  4. Create the disposable AddOutboxLab migration and inspect its SQL before applying.
  5. Modify one work order and add one outbox row in the same SaveChanges call; inject a failure and prove neither commits.
  6. Run the dispatcher with a fake publisher, crash after publish but before marking processed, rerun, and verify the idempotency key exposes duplicate delivery rather than hiding it.
  7. Capture logs/traces for save start, command transaction, success/failure, and outbox processing.

Check your understanding

  1. What is the main difference between an override and an interceptor?
  2. Should a timestamp interceptor decide authorization?
  3. Why is publishing in SavingChanges unsafe?
  4. What makes an outbox write atomic with an aggregate change?
  5. Does an outbox guarantee exactly-once delivery?
  6. Why use a fake TimeProvider?
Review the answers

An override is context-type-local behavior; an interceptor is a composable EF interception service registered in context configuration.

No. Cross-cutting audit metadata does not replace application/domain authorization and invariants.

The external side effect can succeed while the database fails, or vice versa, creating a dual-write inconsistency.

The outbox row is inserted in the same database transaction/SaveChanges unit as the aggregate update.

No. Dispatch retries can redeliver; use stable IDs/idempotency/deduplication.

It makes audit/outbox timestamp behavior deterministic and testable.

8. Production judgment and bridge to Chapter 13

Keep SaveChanges interception small, deterministic, testable, and free of uncontrolled external I/O. Audit metadata may be a pre-save mutation; reliable integration messages need a database-backed outbox plus idempotent post-commit dispatch. Chapter 13 now focuses on the optimistic concurrency predicate already visible in ServiceHub writes: application-managed revisions, SQL Server rowversion, PostgreSQL xmin, and deterministic conflict resolution.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n