Part 1 showed a save as a chain of stages: opening a connection, starting a transaction, executing commands, and committing. Part 2 covered the commands. This part covers the two stages around them.

DbConnectionInterceptor runs when EF Core creates, opens, closes, and disposes a connection. DbTransactionInterceptor runs when EF Core starts, commits, and rolls back a transaction, and when it works with savepoints.

Inside DbConnectionInterceptor

DbConnectionInterceptor follows the same before and after pattern as the other interceptors:

CallbackRuns
ConnectionCreating / ConnectionCreatedBefore and after EF Core creates the DbConnection. Not called when you pass in your own connection
ConnectionOpening / ConnectionOpenedBefore and after EF Core opens the connection
ConnectionClosing / ConnectionClosedBefore and after EF Core closes it
ConnectionDisposing / ConnectionDisposedBefore and after EF Core disposes it
ConnectionFailed / ConnectionCanceledWhen opening throws or is canceled

EF Core opens the connection for each operation and closes it right after. Two queries on the same context mean two opens and two closes, unless you open the connection yourself with OpenConnectionAsync.

When EF Core opens and closes a connectionFor each query or save, EF Core calls ConnectionOpening, opens the connection, calls ConnectionOpened, runs the commands, calls ConnectionClosing, and closes the connection. The next query repeats the cycle. After OpenConnectionAsync, the connection stays open across queries, so the callbacks run once.

Query or SaveChanges

ConnectionOpening

ConnectionOpened

Commands run

ConnectionClosing

Next query: the same cycle again

ConnectionOpening runs while the connection is still closed.

Per-tenant connection strings

In a database-per-tenant setup, each tenant has its own database with the same schema, so only the connection string changes.

The tenant comes from the request, so a scoped abstraction carries it:

public interface ICurrentTenant
{
    string TenantId { get; }
}

Looking up a connection string in a tenant catalog or a secret store is I/O, so the resolver is async:

public interface ITenantConnectionStringResolver
{
    ValueTask<string> ResolveAsync(string tenantId, CancellationToken cancellationToken);
}

Next is the interceptor:

public sealed class TenantConnectionInterceptor(
    ICurrentTenant currentTenant,
    ITenantConnectionStringResolver resolver) : DbConnectionInterceptor
{
    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new InvalidOperationException(
            "Tenant connections need an async lookup. Use the async EF Core APIs.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
    {
        var connectionString = await resolver.ResolveAsync(currentTenant.TenantId, cancellationToken);

        // The connection is still closed here, so its connection string can change.
        if (connection.ConnectionString != connectionString)
        {
            connection.ConnectionString = connectionString;
        }

        return result;
    }
}

ConnectionOpeningAsync resolves the tenant’s connection string and sets it on the connection just before EF Core opens it. It returns result unchanged, so EF Core goes on to open the connection as usual.

The sync ConnectionOpening throws, because a sync open can’t await the lookup. Microsoft’s AAD token example does the same.

Registration

builder.Services.AddSingleton<ITenantConnectionStringResolver, TenantCatalog>();
builder.Services.AddScoped<ICurrentTenant, CurrentTenant>();
builder.Services.AddScoped<TenantConnectionInterceptor>();

builder.Services.AddDbContext<ShopDbContext>((sp, options) =>
    options
        .UseNpgsql()
        .AddInterceptors(sp.GetRequiredService<TenantConnectionInterceptor>()));

UseNpgsql() without a connection string leaves it to the interceptor. The interceptor is scoped because the tenant changes per request, so part 1’s pooling warning applies: with AddDbContextPool, every context would get the first tenant.

Running it

Two tenants, two databases, one DbContext:

var database = await db.Database
    .SqlQuery<string>($"SELECT current_database() AS \"Value\"")
    .SingleAsync();

var products = await db.Products
    .OrderBy(p => p.Name)
    .Select(p => p.Name)
    .ToListAsync();
acme: database tenant_acme, products: Mechanical keyboard, Monitor
globex: database tenant_globex, products: Office chair

Each scope sets its tenant, and the same query reads from a different database. A sync query in the same setup fails before it reaches the database:

Tenant connections need an async lookup. Use the async EF Core APIs.

Inside DbTransactionInterceptor

DbTransactionInterceptor covers the whole life of a transaction:

CallbackRuns
TransactionStarting / TransactionStartedBefore and after EF Core begins a transaction
TransactionUsedWhen you hand EF Core an existing transaction with UseTransaction
TransactionCommitting / TransactionCommittedBefore and after the commit
TransactionRollingBack / TransactionRolledBackBefore and after a rollback
CreatingSavepoint, RollingBackToSavepoint, ReleasingSavepointAround savepoints, each with a matching “after” callback
TransactionFailedWhen a transaction operation throws

EF Core starts a transaction in three cases:

  • Your code asks for one with BeginTransactionAsync.
  • A save runs more than one statement. A one-row save is a single statement that already runs in an implicit transaction, so EF Core skips the transaction and none of these callbacks run.
  • A save runs inside your transaction. EF Core creates a savepoint before saving, and if the save fails, it rolls back to that savepoint instead of losing the whole transaction.

A save that fails inside EF Core’s own transaction rolls it back by disposing it, which calls neither TransactionRolledBack nor TransactionFailed.

Running work only after a commit

Email the customer before the commit, and a rollback leaves them with a confirmation for an order that doesn’t exist.

Queue the work instead, and let the interceptor decide when to run it. The queue is scoped, so each request has its own:

public sealed class AfterCommitQueue
{
    private readonly List<Func<CancellationToken, Task>> pending = [];

    public int Count => pending.Count;

    public void Enqueue(Func<CancellationToken, Task> work) => pending.Add(work);

    internal List<Func<CancellationToken, Task>> TakeAll()
    {
        var work = pending.ToList();
        pending.Clear();
        return work;
    }

    internal void Clear() => pending.Clear();
}

The interceptor runs the queue on a commit and drops it on a rollback:

public sealed class AfterCommitInterceptor(
    AfterCommitQueue queue,
    ILogger<AfterCommitInterceptor> logger) : DbTransactionInterceptor, ISaveChangesInterceptor
{
    public override void TransactionCommitted(DbTransaction transaction, TransactionEndEventData eventData)
        => RunAsync(CancellationToken.None).GetAwaiter().GetResult();

    public override Task TransactionCommittedAsync(
        DbTransaction transaction,
        TransactionEndEventData eventData,
        CancellationToken cancellationToken = default)
        => RunAsync(cancellationToken);

    public override void TransactionRolledBack(DbTransaction transaction, TransactionEndEventData eventData)
        => Drop("Transaction rolled back");

    public override Task TransactionRolledBackAsync(
        DbTransaction transaction,
        TransactionEndEventData eventData,
        CancellationToken cancellationToken = default)
    {
        Drop("Transaction rolled back");
        return Task.CompletedTask;
    }

    private async Task RunAsync(CancellationToken cancellationToken)
    {
        foreach (var work in queue.TakeAll())
        {
            try
            {
                await work(cancellationToken);
            }
            catch (Exception exception)
            {
                // The data is already committed. Throwing here would make the caller
                // think the commit failed, so log and keep going.
                logger.LogError(exception, "After-commit work failed");
            }
        }
    }

    private void Drop(string reason)
    {
        if (queue.Count > 0)
        {
            logger.LogInformation("{Reason}, dropped {Count} queued item(s)", reason, queue.Count);
        }

        queue.Clear();
    }
}

That handles explicit transactions, but not the cases from the previous section. A one-row save never commits a transaction, so TransactionCommitted never runs and the work would wait forever. A failed save never calls TransactionRolledBack, so the work would stay queued and run after the next unrelated commit.

SaveChanges callbacks cover both. ISaveChangesInterceptor has default implementations for every method, so the same class implements only the ones it needs:

public async ValueTask<int> SavedChangesAsync(
    SaveChangesCompletedEventData eventData,
    int result,
    CancellationToken cancellationToken = default)
{
    if (eventData.Context?.Database.CurrentTransaction is null)
    {
        await RunAsync(cancellationToken);
    }

    return result;
}

public Task SaveChangesFailedAsync(
    DbContextErrorEventData eventData,
    CancellationToken cancellationToken = default)
{
    if (eventData.Context?.Database.CurrentTransaction is null)
    {
        Drop("Save failed");
    }

    return Task.CompletedTask;
}

The CurrentTransaction check decides who runs the work. Inside your own transaction, a successful save isn’t committed yet, so the work waits for TransactionCommitted. Outside one, a successful save is already committed. The full version also overrides the sync variants, SaveChangesCanceled, and TransactionFailed.

Register the interceptor next to the queue:

builder.Services.AddScoped<AfterCommitQueue>();
builder.Services.AddScoped<AfterCommitInterceptor>();

builder.Services.AddDbContext<ShopDbContext>((sp, options) =>
    options
        .UseNpgsql()
        .AddInterceptors(
            sp.GetRequiredService<TenantConnectionInterceptor>(),
            sp.GetRequiredService<AfterCommitInterceptor>()));

Running it

With an explicit transaction, the work waits for the commit:

await using var transaction = await db.Database.BeginTransactionAsync();
db.Products.Add(new Product { Name = "USB-C hub" });
await db.SaveChangesAsync();

queue.Enqueue(_ => Say("sending 'USB-C hub added' notification"));
Console.WriteLine("saved, not committed yet");

await transaction.CommitAsync();
Console.WriteLine("commit returned");
saved, not committed yet
after commit: sending 'USB-C hub added' notification
commit returned

Replacing the commit with RollbackAsync drops the work:

info: AfterCommitInterceptor: Transaction rolled back, dropped 1 queued item(s)
rollback returned

A one-row save without a transaction still runs it. The demo traces every connection and transaction callback, and no transaction callback appears:

[trace] ConnectionOpening
[trace] ConnectionOpened (tenant_globex)
[trace] ConnectionClosing
after commit: sending 'Desk lamp added' notification

A save that hits a unique constraint drops it, even though EF Core never reports a rollback:

[trace] TransactionStarting (Unspecified)
[trace] ConnectionClosing
info: AfterCommitInterceptor: Save failed, dropped 1 queued item(s)
save failed: PostgresException, queued items left: 0

Limitations and practical considerations

Connection callbacks run on every open

ConnectionOpeningAsync runs on every open, which means every query. Cache connection strings or tokens instead of calling the catalog database each time.

After-commit work is not guaranteed

The queue lives in memory. If the process stops between the commit and the work, the work is lost. That’s fine for clearing a cache, but not for messages that must reach another system. For those, store the message in the same transaction as the data and send it later, which is the outbox pattern.

Don’t throw after a commit

An exception from TransactionCommittedAsync reaches the caller of CommitAsync and triggers TransactionFailed, although the data is committed. That’s why RunAsync catches and logs.

TransactionScope is invisible

Transactions from TransactionScope bypass DbTransactionInterceptor, so after-commit work queued inside one runs right after the save, before the scope completes.

When to use them

Use caseInterceptorWhy there
Per-tenant connection stringsIDbConnectionInterceptorConnectionOpeningAsync runs before the open and can await the lookup
Access tokens for database authenticationIDbConnectionInterceptorSame callback, and the token can be refreshed before it expires. SqlClient also supports AAD through the connection string
Session settings after the openIDbConnectionInterceptorConnectionOpenedAsync runs on the open connection, before any command
Work that must wait for a commitIDbTransactionInterceptor with ISaveChangesInterceptorCommit and rollback callbacks, plus save callbacks for saves without a transaction
Timing or logging transactionsIDbTransactionInterceptorTransactionCommitted and TransactionRolledBack receive the duration

Final words

Use DbConnectionInterceptor to point each tenant at its own database when EF Core opens the connection, and DbTransactionInterceptor to hold side effects until the data is committed.