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:
| Callback | Runs |
|---|---|
ConnectionCreating / ConnectionCreated | Before and after EF Core creates the DbConnection. Not called when you pass in your own connection |
ConnectionOpening / ConnectionOpened | Before and after EF Core opens the connection |
ConnectionClosing / ConnectionClosed | Before and after EF Core closes it |
ConnectionDisposing / ConnectionDisposed | Before and after EF Core disposes it |
ConnectionFailed / ConnectionCanceled | When 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.
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:
| Callback | Runs |
|---|---|
TransactionStarting / TransactionStarted | Before and after EF Core begins a transaction |
TransactionUsed | When you hand EF Core an existing transaction with UseTransaction |
TransactionCommitting / TransactionCommitted | Before and after the commit |
TransactionRollingBack / TransactionRolledBack | Before and after a rollback |
CreatingSavepoint, RollingBackToSavepoint, ReleasingSavepoint | Around savepoints, each with a matching “after” callback |
TransactionFailed | When 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 case | Interceptor | Why there |
|---|---|---|
| Per-tenant connection strings | IDbConnectionInterceptor | ConnectionOpeningAsync runs before the open and can await the lookup |
| Access tokens for database authentication | IDbConnectionInterceptor | Same callback, and the token can be refreshed before it expires. SqlClient also supports AAD through the connection string |
| Session settings after the open | IDbConnectionInterceptor | ConnectionOpenedAsync runs on the open connection, before any command |
| Work that must wait for a commit | IDbTransactionInterceptor with ISaveChangesInterceptor | Commit and rollback callbacks, plus save callbacks for saves without a transaction |
| Timing or logging transactions | IDbTransactionInterceptor | TransactionCommitted 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.