EF Core interceptors are hooks into the EF Core pipeline that fire before and after operations — saving changes, executing commands, and opening connections. They enable cross-cutting concerns (audit logging, soft delete, query tagging) to be implemented once and applied automatically to all operations. Unlike triggers (which are database-level), interceptors run in the .NET process — they have access to the full application context (current user, request ID, HTTP context) and can be unit tested.
EF Core Interceptors
// ── Interface for auditable entities ──────────────────────────────────────
public interface IAuditableEntity
{
DateTime CreatedAt { get; set; }
DateTime UpdatedAt { get; set; }
string? CreatedBy { get; set; }
string? UpdatedBy { get; set; }
}
// ── SaveChanges interceptor — auto-set audit fields ───────────────────────
public class AuditInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUserService _currentUser;
public AuditInterceptor(ICurrentUserService currentUser)
=> _currentUser = currentUser;
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var context = eventData.Context!;
var now = DateTime.UtcNow;
var userId = _currentUser.UserId;
foreach (var entry in context.ChangeTracker.Entries<IAuditableEntity>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.CreatedBy = userId;
entry.Entity.UpdatedAt = now;
entry.Entity.UpdatedBy = userId;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
entry.Entity.UpdatedBy = userId;
// Don't overwrite CreatedAt/CreatedBy on updates
entry.Property(e => e.CreatedAt).IsModified = false;
entry.Property(e => e.CreatedBy).IsModified = false;
}
}
return base.SavingChangesAsync(eventData, result, ct);
}
}
// ── Soft delete interceptor ────────────────────────────────────────────────
public class SoftDeleteInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct)
{
foreach (var entry in eventData.Context!.ChangeTracker
.Entries<ISoftDeletable>()
.Where(e => e.State == EntityState.Deleted))
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
}
return base.SavingChangesAsync(eventData, result, ct);
}
}
// ── DbCommand interceptor — add query tags ────────────────────────────────
public class QueryTagInterceptor : DbCommandInterceptor
{
public override ValueTask<DbCommand> CommandCreatedAsync(
CommandEndEventData data, DbCommand result, CancellationToken ct = default)
{
// Prepend a SQL comment with the EF Core query tag:
if (data.Command.CommandText.StartsWith("-- "))
return new ValueTask<DbCommand>(result);
result.CommandText = $"-- EF Core | {DateTime.UtcNow:HH:mm:ss}{Environment.NewLine}"
+ result.CommandText;
return new ValueTask<DbCommand>(result);
}
}
// Or use TagWith() on individual queries:
// _db.Posts.TagWith("GetPublishedPosts endpoint").Where(...).ToListAsync()
// Adds: -- GetPublishedPosts endpoint to the SQL
// ── Register interceptors ─────────────────────────────────────────────────
services.AddScoped<AuditInterceptor>();
services.AddScoped<SoftDeleteInterceptor>();
services.AddDbContext<AppDbContext>((sp, opts) => {
opts.UseSqlServer(connStr)
.AddInterceptors(
sp.GetRequiredService<AuditInterceptor>(),
sp.GetRequiredService<SoftDeleteInterceptor>()
);
});
ICurrentUserService) using AddInterceptors(sp.GetRequiredService<T>()) on the DbContextOptionsBuilder — not directly in the AddDbContext() options action. The interceptor instance must be created from the DI container per-request to get the correct scoped ICurrentUserService for the current user. Registering the interceptor directly in options creates a singleton interceptor that cannot access per-request services.TagWith("QueryName") on EF Core queries to add SQL comments visible in SQL Server Profiler, Extended Events, and the Active Monitor. This makes it easy to correlate slow queries in SQL Server’s activity log to specific application code paths. Use the endpoint name or operation name as the tag: _db.Posts.TagWith("GET /api/posts").Where(...). The comment appears in the SQL as -- GET /api/posts before the SELECT statement.EntityState.Deleted to EntityState.Modified with IsDeleted = true. This means EF Core does not send a DELETE statement — it sends an UPDATE. But the entity’s IsDeleted property must be tracked in the model for this to work. If a developer removes IsDeleted from the entity and you forget to update the interceptor, soft deletes silently start performing hard deletes. Always test the soft-delete interceptor path explicitly in integration tests.Common Mistakes
Mistake 1 — Singleton interceptor with scoped service dependency (wrong user in audit)
❌ Wrong — AddInterceptors(new AuditInterceptor(currentUser)) at startup; uses the startup-time user forever.
✅ Correct — register as scoped service; resolve from DI container per-request via sp.GetRequiredService<AuditInterceptor>().
Mistake 2 — Soft delete interceptor without global query filter (deleted items still appear)
❌ Wrong — interceptor sets IsDeleted = true but no HasQueryFilter; deleted posts still appear in all queries.
✅ Correct — pair soft delete interceptor with HasQueryFilter(p => !p.IsDeleted) on the entity.