EF Core Interceptors in .NET
Why EF Core Interceptors Matter
Most applications eventually need behavior that wraps database operations without cluttering every repository or service.
Examples:
- auditing
CreatedOnUtcandModifiedOnUtc - writing Outbox messages automatically
- logging slow queries
- enforcing multi-tenant rules
- inspecting SQL commands before execution
A naive approach usually spreads this logic across services, repositories, or DbContext methods. That works at first, but it creates duplication and inconsistency.
EF Core interceptors provide a cleaner alternative.
What Are EF Core Interceptors?
Interceptors let you plug into EF Core’s internal pipeline and react to important events such as:
- saving changes
- executing commands
- opening connections
- handling transactions
- materializing entities
They are a powerful way to implement cross-cutting persistence behavior in one place.
Interceptors vs Overriding SaveChanges
A lot of teams start here:
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// custom logic
return base.SaveChangesAsync(cancellationToken);
}
This is sometimes fine, but interceptors are usually a better fit when:
- you want reusable behavior across multiple contexts
- you want better separation of concerns
- you need to intercept more than just
SaveChanges - you want to compose multiple behaviors cleanly
Think of overrides as local customization and interceptors as pipeline-level extensibility.
Common Interceptor Types
Some of the most useful EF Core interceptors are:
SaveChangesInterceptorDbCommandInterceptorDbConnectionInterceptorDbTransactionInterceptorIMaterializationInterceptor
For most business applications, SaveChangesInterceptor and DbCommandInterceptor are the most common starting points.
1. SaveChangesInterceptor for Auditing
A classic use case is automatically updating audit fields.
Auditable entity contract
public interface IAuditableEntity
{
DateTime CreatedOnUtc { get; set; }
DateTime? ModifiedOnUtc { get; set; }
}
Interceptor implementation
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
public sealed class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var dbContext = eventData.Context;
if (dbContext is null)
{
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
var utcNow = DateTime.UtcNow;
foreach (var entry in dbContext.ChangeTracker.Entries<IAuditableEntity>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedOnUtc = utcNow;
}
if (entry.State == EntityState.Modified)
{
entry.Entity.ModifiedOnUtc = utcNow;
}
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
This keeps audit behavior out of your services and repositories.
Registering Interceptors
You typically register interceptors when configuring your DbContext:
builder.Services.AddSingleton<AuditSaveChangesInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString);
options.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>());
});
This pattern works well because interceptors become part of the DI graph.
2. SaveChangesInterceptor for the Outbox Pattern
Interceptors are also a strong fit for writing Outbox rows from domain events.
Instead of doing this manually in every command handler, a SaveChangesInterceptor can:
- inspect tracked aggregates
- pull domain events from them
- serialize those events into
OutboxMessagerows - let EF save everything in one transaction
Example shape
public interface IHasDomainEvents
{
IReadOnlyCollection<IDomainEvent> DomainEvents { get; }
void ClearDomainEvents();
}
public sealed class OutboxSaveChangesInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var dbContext = eventData.Context;
if (dbContext is null)
{
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
var domainEventEntries = dbContext.ChangeTracker
.Entries<IHasDomainEvents>()
.Where(x => x.Entity.DomainEvents.Count > 0)
.ToList();
var outboxMessages = new List<OutboxMessage>();
foreach (var entry in domainEventEntries)
{
foreach (var domainEvent in entry.Entity.DomainEvents)
{
outboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
OccurredOnUtc = DateTime.UtcNow,
Type = domainEvent.GetType().Name,
Payload = JsonSerializer.Serialize(domainEvent)
});
}
entry.Entity.ClearDomainEvents();
}
if (outboxMessages.Count > 0)
{
dbContext.Set<OutboxMessage>().AddRange(outboxMessages);
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
This is one of the best real-world examples of interceptor value.
3. DbCommandInterceptor for Query Monitoring
Sometimes you want insight into SQL execution itself.
Use case
- detect slow queries
- log suspicious SQL
- add telemetry
- inspect command text and parameters
Example
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
public sealed class SlowQueryInterceptor : DbCommandInterceptor
{
private readonly ILogger<SlowQueryInterceptor> _logger;
public SlowQueryInterceptor(ILogger<SlowQueryInterceptor> logger)
{
_logger = logger;
}
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
if (eventData.Duration > TimeSpan.FromMilliseconds(500))
{
_logger.LogWarning(
"Slow SQL query detected ({Duration} ms): {CommandText}",
eventData.Duration.TotalMilliseconds,
command.CommandText);
}
return await base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
}
This is useful when you need visibility without modifying every query call site.
4. Connection and Transaction Interceptors
These are less common in application code, but still useful.
Typical scenarios:
- add diagnostics when connections open/close
- track transaction start/commit/rollback
- integrate with observability platforms
- capture infrastructure-level telemetry
These are usually more relevant in mature systems with strong operational monitoring requirements.
5. Materialization Interceptors
IMaterializationInterceptor allows you to run logic when EF creates entity instances from query results.
Use cases can include:
- lightweight initialization logic
- enforcing internal invariants after hydration
- attaching metadata that should not live in constructors
This is an advanced tool and should be used carefully. If overused, it can make object lifecycle harder to understand.
When Interceptors Are a Good Fit
Interceptors are valuable when the behavior is:
- cross-cutting
- persistence-related
- repeatable across many operations
- awkward to enforce manually everywhere
Examples that fit well:
- auditing
- Outbox writes
- SQL timing/logging
- soft-delete transformations
- tenant guards
When Interceptors Are a Bad Fit
Avoid interceptors when the logic is:
- business-specific and not generic
- hard to understand outside the request flow
- likely to surprise future maintainers
If the behavior changes the business meaning of an operation in a non-obvious way, it may belong in an explicit service or domain workflow instead.
Common Mistakes
1. Hiding too much behavior
If developers cannot explain what happens during SaveChanges, you may have over-abstracted.
2. Mixing business rules into infrastructure interception
Interceptors should usually stay focused on persistence/infrastructure concerns.
3. Registering too many overlapping interceptors
Multiple interceptors are fine, but they should remain predictable and composable.
4. Forgetting performance implications
Interceptors run often. Avoid heavy reflection, expensive serialization, or unnecessary allocations in hot paths.
Practical Recommendation
A strong default approach is:
- use
SaveChangesInterceptorfor audit and Outbox scenarios - use
DbCommandInterceptorfor telemetry and slow query logging - keep the logic simple and explicit
- document the active interceptors in the data layer
That gives you most of the benefit without turning EF Core into a hidden magic engine.
Summary
EF Core interceptors are one of the most useful extension points in the persistence stack.
They help centralize concerns like:
- auditing
- Outbox writing
- SQL monitoring
- infrastructure telemetry
Used well, they reduce duplication and keep data-access behavior consistent. Used poorly, they make persistence harder to reason about.
The right approach is pragmatic: use interceptors for infrastructure concerns that truly benefit from being centralized, and keep business workflows explicit.