.NET & C# Development · Lesson 28 of 92
Intercept Every Save — Add Audit Trails Automatically
What Interceptors Do
EF Core interceptors are low-level hooks that fire before and after database operations. Unlike SavingChanges event handlers on the context, interceptors are proper pipeline components: they receive the operation, can modify it, suppress it, or pass it through.
Three main interceptor types:
ISaveChangesInterceptor— fires aroundSaveChanges[Async]IDbCommandInterceptor— fires around every SQL command executionIDbConnectionInterceptor— fires around connection open/close
Building an Audit Interceptor
Define marker interfaces on your entities:
public interface IHasAuditDates
{
DateTime CreatedAt { get; set; }
DateTime UpdatedAt { get; set; }
}
public interface IHasCreatedBy
{
string CreatedBy { get; set; }
string UpdatedBy { get; set; }
}Implement on your entities:
public class Article : IHasAuditDates, IHasCreatedBy
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public string UpdatedBy { get; set; } = string.Empty;
}Now the interceptor:
using Microsoft.EntityFrameworkCore.Diagnostics;
public class AuditInterceptor : SaveChangesInterceptor
{
private readonly IHttpContextAccessor _httpContextAccessor;
public AuditInterceptor(IHttpContextAccessor httpContextAccessor)
=> _httpContextAccessor = httpContextAccessor;
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is null) return base.SavingChangesAsync(eventData, result, cancellationToken);
var now = DateTime.UtcNow;
var user = _httpContextAccessor.HttpContext?.User?.Identity?.Name ?? "system";
foreach (var entry in eventData.Context.ChangeTracker.Entries())
{
if (entry.Entity is IHasAuditDates auditable)
{
if (entry.State == EntityState.Added)
auditable.CreatedAt = now;
if (entry.State is EntityState.Added or EntityState.Modified)
auditable.UpdatedAt = now;
}
if (entry.Entity is IHasCreatedBy createdBy)
{
if (entry.State == EntityState.Added)
createdBy.CreatedBy = user;
if (entry.State is EntityState.Added or EntityState.Modified)
createdBy.UpdatedBy = user;
}
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}Registering Interceptors in AddDbContext
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuditInterceptor>();
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(
serviceProvider.GetRequiredService<AuditInterceptor>()
);
});Interceptors that depend on scoped services (like IHttpContextAccessor) must themselves be scoped and resolved from the service provider — not newed up directly.
Soft Delete Interceptor
Instead of deleting rows, set a DeletedAt timestamp and filter them out globally:
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
}
public class SoftDeleteInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is null) return base.SavingChangesAsync(eventData, result, cancellationToken);
foreach (var entry in eventData.Context.ChangeTracker.Entries<ISoftDeletable>())
{
if (entry.State != EntityState.Deleted) continue;
// Convert hard delete to soft delete
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}Add a global query filter so soft-deleted rows never appear in queries:
// In AppDbContext.OnModelCreating:
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType))
{
modelBuilder.Entity(entityType.ClrType)
.HasQueryFilter(BuildSoftDeleteFilter(entityType.ClrType));
}
}
private static LambdaExpression BuildSoftDeleteFilter(Type type)
{
// Generates: e => !e.IsDeleted
var param = Expression.Parameter(type, "e");
var body = Expression.Not(
Expression.Property(param, nameof(ISoftDeletable.IsDeleted)));
return Expression.Lambda(body, param);
}Slow Query Interceptor
IDbCommandInterceptor fires around every SQL execution. Use it to log commands that exceed a threshold:
public class SlowQueryInterceptor : DbCommandInterceptor
{
private readonly ILogger<SlowQueryInterceptor> _logger;
private readonly TimeSpan _threshold;
public SlowQueryInterceptor(ILogger<SlowQueryInterceptor> logger)
{
_logger = logger;
_threshold = TimeSpan.FromMilliseconds(200);
}
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
if (eventData.Duration > _threshold)
{
_logger.LogWarning(
"Slow query detected ({DurationMs}ms):\n{Sql}",
eventData.Duration.TotalMilliseconds,
command.CommandText);
}
return await base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
}ISaveChangesInterceptor — Full Interface
The interface exposes both sync and async hooks:
public interface ISaveChangesInterceptor : IInterceptor
{
// Before SaveChanges — can suppress the call
InterceptionResult<int> SavingChanges(DbContextEventData data, InterceptionResult<int> result);
ValueTask<InterceptionResult<int>> SavingChangesAsync(...);
// After SaveChanges — receives the row count
int SavedChanges(SaveChangesCompletedEventData data, int result);
ValueTask<int> SavedChangesAsync(...);
// If SaveChanges throws
void SaveChangesFailed(DbContextErrorEventData data);
Task SaveChangesFailedAsync(...);
}SaveChangesInterceptor is the base class that provides no-op implementations — extend it and override only what you need.
Multiple Interceptors
You can register as many interceptors as you like. They run in registration order:
options.UseSqlServer(connectionString)
.AddInterceptors(
serviceProvider.GetRequiredService<AuditInterceptor>(),
serviceProvider.GetRequiredService<SoftDeleteInterceptor>(),
serviceProvider.GetRequiredService<SlowQueryInterceptor>()
);Suppressing a Save
You can return a suppressed result to prevent the actual database call — useful for testing or conditional writes:
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (_dryRunMode)
{
// Return a suppressed result — EF skips the database call
return ValueTask.FromResult(InterceptionResult<int>.SuppressWithResult(0));
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}What Not to Put in Interceptors
Interceptors run synchronously inside the EF pipeline. Avoid:
- External HTTP calls (use domain events published after commit instead)
- Long-running operations
- Throwing exceptions for business rule violations (use domain logic before calling
SaveChanges)
Interceptors are infrastructure. Keep them focused on cross-cutting concerns: timestamps, soft deletes, logging.