A quick word from me
This issue isn't sponsored - I write these deep dives in my free time and keep them free for everyone. If your company sells AI tools, dev tools, courses, or services that .NET developers would actually use, sponsoring an issue is the most direct way to reach them.
Want to reach thousands of .NET developers? Sponsor TheCodeMan âThis issue is made possible thanks to ZZZ Projects, who help keep this newsletter free for everyone. A huge shout-out to them for their support of our community. Let's thank them by entering the link below. EF Core too slow? Insert data 14x faster and cut saving time by 94%. đ Boost performance with Bulk Insert Check EF Extensions here.
Introduction
Most teams start with the same two problems:
- âWe need audit fields everywhere (CreatedAt, UpdatedAt, UpdatedByâŠ), and we keep forgetting to set them.â
- âWe need better visibility into SQL in production (correlation IDs, tenant IDs, slow query alerts).â
The usual outcome: your DbContext.SaveChanges override becomes a junk drawer, or every repository repeats the same cross-cutting logic.
EF Core interceptors exist exactly for this: they let you intercept, modify, or suppress EF operations at different pipeline stages - commands, connections, transactions, SaveChanges, materialization, etc.
The domain: âSaaS Billingâ with audit + compliance needs
Imagine a billing system where:
- Every row must be attributable to a user and tenant.
- Deletes must be soft (regulators + âoopsâ recovery).
When a payment job spikes DB load, you want slow query logs tied to the request/job ID. Weâll solve this with two interceptors.
1) Auditing + soft delete with SaveChangesInterceptor
Step 1 - Define a tiny audit/soft-delete contract
public interface IAuditableEntity{ DateTimeOffset CreatedAt { get; set; } string? CreatedBy { get; set; } DateTimeOffset UpdatedAt { get; set; } string? UpdatedBy { get; set; }}Â public interface ISoftDelete{ bool IsDeleted { get; set; } DateTimeOffset? DeletedAt { get; set; } string? DeletedBy { get; set; }}
Why this matters: Interceptors work best when you can apply logic by capability (interfaces), instead of hardcoding entity types.
Step 2 - Example entity
public sealed class Invoice : IAuditableEntity, ISoftDelete{ Guid Id { get; set; } Guid TenantId { get; set; } decimal Amount { get; set; } string Currency { get; set; } = "EUR"; DateTimeOffset CreatedAt { get; set; } string? CreatedBy { get; set; } DateTimeOffset UpdatedAt { get; set; } string? UpdatedBy { get; set; } bool IsDeleted { get; set; } DateTimeOffset? DeletedAt { get; set; } string? DeletedBy { get; set; }}
Whatâs ârealâ here: Billing data often requires immutability/auditability, and soft deletes are a very common compliance-friendly default.
Step 3 - âCurrent userâ abstraction (works for APIs + background jobs)
public interface ICurrentActor{ string? UserId { get; } Guid? TenantId { get; } string CorrelationId { get; }}Â public sealed class CurrentActor : ICurrentActor{ public CurrentActor(IHttpContextAccessor accessor) { var http = accessor.HttpContext;Â UserId = http?.User?.Identity?.Name; TenantId = Guid.TryParse(http?.Request.Headers["X-Tenant-Id"], out var tid) ? tid : null;Â CorrelationId = http?.TraceIdentifier ?? Activity.Current?.Id ?? Guid.NewGuid().ToString("N"); }Â public string? UserId { get; } public Guid? TenantId { get; } public string CorrelationId { get; }}
Why this matters: Interceptors are cross-cutting. You need one place to define âwho is doing the actionâ and âwhat request/job is thisâ.
Step 4 - The interceptor itself
using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Diagnostics;Â public sealed class AuditAndSoftDeleteInterceptor : SaveChangesInterceptor{ private readonly ICurrentActor _actor;Â public AuditAndSoftDeleteInterceptor(ICurrentActor actor) => _actor = actor;Â public override InterceptionResult<int> SavingChanges( DbContextEventData eventData, InterceptionResult<int> result) { ApplyRules(eventData.Context); return base.SavingChanges(eventData, result); }Â public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { ApplyRules(eventData.Context); return base.SavingChangesAsync(eventData, result, cancellationToken); }Â private void ApplyRules(DbContext? context) { if (context is null) return;Â var now = DateTimeOffset.UtcNow; var user = _actor.UserId ?? "system";Â foreach (var entry in context.ChangeTracker.Entries()) { if (entry.Entity is IAuditableEntity auditable) { if (entry.State == EntityState.Added) { auditable.CreatedAt = now; auditable.CreatedBy = user; }Â if (entry.State is EntityState.Added or EntityState.Modified) { auditable.UpdatedAt = now; auditable.UpdatedBy = user; } }Â if (entry.Entity is ISoftDelete softDelete && entry.State == EntityState.Deleted) { // Convert hard delete into soft delete entry.State = EntityState.Modified;Â softDelete.IsDeleted = true; softDelete.DeletedAt = now; softDelete.DeletedBy = user;Â // Also counts as an update if (entry.Entity is IAuditableEntity a) { a.UpdatedAt = now; a.UpdatedBy = user; } } } }}
What this gives you:
- Nobody can âforgetâ audit fields anymore.
- Nobody can accidentally hard-delete rows (unless they bypass EF, which you can also detect via DB perms).
- Your DbContext stays clean.
2) SQL observability with DbCommandInterceptor (tagging + slow query logging)
This is one of my favorite âproduction maturityâ uses of interceptors:
- Attach a correlation ID and tenant ID to SQL as a comment.
- Log slow queries with that same metadata. ### Step 1 - The interceptor
using System.Data.Common;using System.Diagnostics;using Microsoft.EntityFrameworkCore.Diagnostics;using Microsoft.Extensions.Logging;Â public sealed class ObservabilityCommandInterceptor : DbCommandInterceptor{ private readonly ICurrentActor _actor; private readonly ILogger<ObservabilityCommandInterceptor> _logger;Â // Tune for your system private static readonly TimeSpan SlowQueryThreshold = TimeSpan.FromMilliseconds(250);Â public ObservabilityCommandInterceptor( ICurrentActor actor, ILogger<ObservabilityCommandInterceptor> logger) { _actor = actor; _logger = logger; }Â public override InterceptionResult<DbDataReader> ReaderExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result) { Tag(command); eventData.Context?.Items.TryAdd(eventData.CommandId, Stopwatch.StartNew()); return base.ReaderExecuting(command, eventData, result); }Â public override void ReaderExecuted( DbCommand command, CommandExecutedEventData eventData, DbDataReader result) { LogIfSlow(eventData, command); base.ReaderExecuted(command, eventData, result); }Â public override InterceptionResult<int> NonQueryExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<int> result) { Tag(command); eventData.Context?.Items.TryAdd(eventData.CommandId, Stopwatch.StartNew()); return base.NonQueryExecuting(command, eventData, result); }Â public override void NonQueryExecuted( DbCommand command, CommandExecutedEventData eventData, int result) { LogIfSlow(eventData, command); base.NonQueryExecuted(command, eventData, result); }Â public override InterceptionResult<object> ScalarExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<object> result) { Tag(command); eventData.Context?.Items.TryAdd(eventData.CommandId, Stopwatch.StartNew()); return base.ScalarExecuting(command, eventData, result); }Â public override void ScalarExecuted( DbCommand command, CommandExecutedEventData eventData, object result) { LogIfSlow(eventData, command); base.ScalarExecuted(command, eventData, result); }Â private void Tag(DbCommand command) { var tenant = _actor.TenantId?.ToString() ?? "none"; var corr = _actor.CorrelationId;Â // SQL comments are ignored by most engines but show up in logs/traces // Keep it short to avoid huge command texts command.CommandText = $"/* tenant:{tenant} corr:{corr} */\n{command.CommandText}"; }Â private void LogIfSlow(CommandExecutedEventData eventData, DbCommand command) { if (eventData.Context is null) return;Â if (eventData.Context.Items.TryGetValue(eventData.CommandId, out var swObj) && swObj is Stopwatch sw) { sw.Stop();Â if (sw.Elapsed >= SlowQueryThreshold) { _logger.LogWarning( "Slow SQL ({ElapsedMs} ms) tenant:{TenantId} corr:{CorrelationId}\n{CommandText}", sw.Elapsed.TotalMilliseconds, _actor.TenantId, _actor.CorrelationId, command.CommandText); }Â eventData.Context.Items.Remove(eventData.CommandId); } }}
Why this is powerful:
- When production slows down, youâll see which request/job caused the expensive SQL.
- Your DBA/observability stack can correlate SQL traces back to app traces. Also important: Microsoft explicitly positions interceptors as more than loggingâthey can modify operations.
3) Wire it up in .NET 9 (DI + AddInterceptors)
using Microsoft.EntityFrameworkCore;Â var builder = WebApplication.CreateBuilder(args);Â builder.Services.AddHttpContextAccessor();builder.Services.AddScoped<ICurrentActor, CurrentActor>();Â // Interceptors: usually safe as singleton if they are stateless.// Ours depends on scoped ICurrentActor, so we register them as scoped.builder.Services.AddScoped<AuditAndSoftDeleteInterceptor>();builder.Services.AddScoped<ObservabilityCommandInterceptor>();Â builder.Services.AddDbContext<BillingDbContext>((sp, options) =>{ options.UseNpgsql(builder.Configuration.GetConnectionString("db"));Â // Add interceptors from DI options.AddInterceptors( sp.GetRequiredService<AuditAndSoftDeleteInterceptor>(), sp.GetRequiredService<ObservabilityCommandInterceptor>());});Â var app = builder.Build();app.MapGet("/", () => "OK");app.Run();
Key detail about execution order: EF Core can run interceptors coming from DI (âinjectedâ) and those added directly to the context (âapplication interceptorsâ). Injected ones run first (in resolution order), then application interceptors run in the order they were added.
Practical tips (the stuff that bites in real projects)
Keep interceptors fast. If your interceptor does heavy work, you just moved latency into the hot path.
Be careful with service lifetimes. If you register an interceptor as a singleton but it depends on scoped services, youâll have a bad day.
Donât call EF from inside a command interceptor (you can create recursion or deadlocks).
Prefer interceptors for cross-cutting concerns, and keep domain rules in the domain.
Wrapping up
Interceptors are one of those EF Core features that quietly upgrade your codebase maturity:
- Your DbContext stays focused on modeling and mapping.
- Auditing/soft-delete becomes consistent and automatic.
- SQL observability stops being âbest effortâ and becomes built-in. If you want to go even further, the next âadultâ use case is an Outbox pattern using SaveChangesInterceptor (capture domain events, persist them, publish reliably) - Milan has a great article about it.
That's all for today. P.S. Iâm currently building a new course, Pragmatic .NET Code Rules, focused on creating a predictable, consistent, and self-maintaining .NET codebase using .editorconfig, analyzers, Visual Studio code cleanup, and CI enforcement. The course is available in presale until the official release, with early-bird pricing for early adopters. You can find all the details here.





