EF Core Interceptors
Hello again! If you followed the Enhance EF Core Performance series, you might remember I promised to come back and talk about interceptors. Well, here we are.
Interceptors let you plug into EF Core’s internal pipeline and observe (or change) what it’s about to do, before it does it. Think of them as middleware for your DbContext: every time EF Core is about to open a connection, run a command, or save changes, an interceptor gets a chance to look at the operation, log it, tweak it, or even cancel it.
In this post, we’ll build two real interceptors from scratch:
- An auditing interceptor that automatically stamps
CreatedDate/ModifiedDateon your entities. - A soft-delete interceptor that turns every
Deleteinto anUpdate, so nothing ever really disappears from your database.
What Are Interceptors?
EF Core interceptors are classes that implement one of the IInterceptor sub-interfaces, such as ISaveChangesInterceptor, IDbCommandInterceptor, or IDbConnectionInterceptor. Each interface exposes hook methods that fire before/after a specific stage of the pipeline (SavingChanges, SavedChanges, ReaderExecuting, ConnectionOpening, and so on).
Compared to overriding SaveChanges() in your DbContext, interceptors have two advantages:
- They’re reusable across multiple
DbContexttypes without duplicating code. - They’re composable you can register several interceptors, and EF Core runs them all in the pipeline.
Let’s put that to work.
Auditing Interceptor
A very common requirement: every entity should record when it was created and when it was last modified, without every service/repository having to remember to set those fields manually.
Implementation
Step 1
Define the contract our auditable entities implement:
public interface IAuditable
{
DateTime CreatedDate { get; set; }
DateTime? ModifiedDate { get; set; }
}
public class Blog : IAuditable
{
public int Id { get; set; }
public string Name { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedDate { get; set; }
}
Step 2
Create the interceptor by implementing SaveChangesInterceptor and overriding SavingChanges / SavingChangesAsync:
using Microsoft.EntityFrameworkCore.Diagnostics;
public class AuditingInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
ApplyAuditInfo(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
ApplyAuditInfo(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void ApplyAuditInfo(DbContext? context)
{
if (context is null) return;
var entries = context.ChangeTracker.Entries<IAuditable>();
foreach (var entry in entries)
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedDate = DateTime.UtcNow;
break;
case EntityState.Modified:
entry.Entity.ModifiedDate = DateTime.UtcNow;
break;
}
}
}
}
Step 3
Register the interceptor when configuring the DbContext:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<BloggingDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))
.AddInterceptors(new AuditingInterceptor())); // Add AuditingInterceptor to the pipeline
var app = builder.Build();
app.Run();
Step 4
That’s it, no repository or service needs to know about auditing anymore:
using var context = new BloggingDbContext();
var blog = new Blog { Name = "EF Core Interceptors" };
context.Blogs.Add(blog);
context.SaveChanges(); // CreatedDate is set automatically
blog.Name = "EF Core Interceptors - Updated";
context.SaveChanges(); // ModifiedDate is set automatically
You can find the full source code used in this post here
Limitations
- The interceptor only sees entities that are already tracked by the
ChangeTracker- bulk update/delete APIs (likeExecuteUpdate/ExecuteDelete) bypass it entirely. - If you register the interceptor per-
DbContextinstance (new AuditingInterceptor()), make sure it’s stateless - otherwise share it via DI as shown in the caching post to avoid subtle bugs. - It won’t help with raw SQL executed through
FromSqlRaworDatabase.ExecuteSqlRaw, since those don’t go through change tracking.
Soft-Delete Interceptor
Physically deleting rows is often not what you want which leads to lose history, break foreign keys, and can’t “undo” a mistake. A soft-delete interceptor intercepts the delete and rewrites it into an update instead.
Implementation
Step 1
Define a marker interface for soft-deletable entities:
public interface ISoftDelete
{
bool IsDeleted { get; set; }
}
public class Blog : IAuditable, ISoftDelete
{
public int Id { get; set; }
public string Name { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedDate { get; set; }
public bool IsDeleted { get; set; }
}
Step 2
Intercept SavingChanges and rewrite Deleted entries into Modified ones:
public class SoftDeleteInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
ApplySoftDelete(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
ApplySoftDelete(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void ApplySoftDelete(DbContext? context)
{
if (context is null) return;
var entries = context.ChangeTracker.Entries<ISoftDelete>()
.Where(e => e.State == EntityState.Deleted);
foreach (var entry in entries)
{
entry.State = EntityState.Modified; // Turn the delete into an update
entry.Entity.IsDeleted = true;
}
}
}
Step 3
Register it alongside the auditing interceptor EF Core runs every registered interceptor in the pipeline:
builder.Services.AddDbContext<BloggingDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))
.AddInterceptors(new AuditingInterceptor(), new SoftDeleteInterceptor()));
Step 4
Deleting now never removes the row:
using var context = new BloggingDbContext();
var blog = context.Blogs.First();
context.Blogs.Remove(blog);
context.SaveChanges(); // UPDATE Blogs SET IsDeleted = 1 ... instead of DELETE
Don’t forget to add a global query filter so soft-deleted rows are excluded from normal queries by default:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>().HasQueryFilter(b => !b.IsDeleted);
}
Limitations
- Cascading deletes configured at the database level (
ON DELETE CASCADE) won’t know about soft-delete, related rows can still be physically removed unless you also soft-delete them explicitly or remove the cascade behavior. - Unique indexes and constraints need to account for
IsDeleted, otherwise you can’t reuse a “deleted” value (e.g., an email address) for a new row. - Every query needs the global filter (or
IgnoreQueryFilters()when you intentionally need deleted rows), which is easy to forget on raw SQL orExecuteDelete/ExecuteUpdatecalls.
References
Below are the references and resources I used to prepare this post:
- https://learn.microsoft.com/en-us/ef/core/logging-events-diagnostics/interceptors
- https://learn.microsoft.com/en-us/ef/core/querying/filters
That wraps up this look at EF Core interceptors. Auditing and soft-delete are just two use cases. The same pattern applies to logging, retry policies, or even second-level caching, as we saw in the performance series. Once you see the pipeline, it’s hard to unsee how many cross-cutting concerns you can move out of your repositories and into a single, reusable interceptor.
Leave a comment