EF Core Interceptors — Hooking into Database Operations
Use EF Core interceptors to add cross-cutting concerns: audit logging on SaveChanges, soft delete automation, query tagging, command interception for performance monitoring, and transaction interceptors.
EF Core Interceptors Overview
Interceptors hook into the EF Core pipeline at specific points:
ISaveChangesInterceptor: before/after SaveChanges
IDbCommandInterceptor: before/after SQL command execution
IDbConnectionInterceptor: connection open/close
IDbTransactionInterceptor: transaction begin/commit/rollback
IQueryExpressionInterceptor: query expression tree manipulation
Most common use cases:
✓ Audit logging (who changed what, when)
✓ Soft delete automation (set IsDeleted instead of deleting)
✓ Automatic timestamp updates (UpdatedAt, CreatedAt)
✓ Command performance monitoring
✓ Query tagging with user/request contextAudit Interceptor
// Infrastructure/Persistence/Interceptors/AuditInterceptor.cs
public sealed class AuditInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUser _currentUser;
public AuditInterceptor(ICurrentUser currentUser)
=> _currentUser = currentUser;
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
UpdateAuditableEntities(eventData.Context!);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
UpdateAuditableEntities(eventData.Context!);
return base.SavingChangesAsync(eventData, result, ct);
}
private void UpdateAuditableEntities(DbContext context)
{
var userId = _currentUser.UserId?.ToString() ?? "system";
var timestamp = DateTime.UtcNow;
foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Property(nameof(IAuditable.CreatedAt)).CurrentValue = timestamp;
entry.Property(nameof(IAuditable.CreatedBy)).CurrentValue = userId;
}
if (entry.State is EntityState.Added or EntityState.Modified)
{
entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = timestamp;
entry.Property(nameof(IAuditable.UpdatedBy)).CurrentValue = userId;
}
}
}
}
public interface IAuditable
{
DateTime CreatedAt { get; }
string CreatedBy { get; }
DateTime UpdatedAt { get; }
string UpdatedBy { get; }
}Soft Delete Interceptor
public sealed 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 ct = default)
{
ApplySoftDelete(eventData.Context!);
return base.SavingChangesAsync(eventData, result, ct);
}
private static void ApplySoftDelete(DbContext context)
{
foreach (var entry in context.ChangeTracker.Entries<ISoftDeletable>()
.Where(e => e.State == EntityState.Deleted))
{
entry.State = EntityState.Modified; // prevent actual DELETE
entry.Entity.Delete(); // set IsDeleted = true, DeletedAt = now
}
}
}
public interface ISoftDeletable
{
bool IsDeleted { get; }
DateTime? DeletedAt { get; }
void Delete();
}Command Interceptor for Performance Monitoring
public sealed class SlowQueryInterceptor : DbCommandInterceptor
{
private readonly ILogger<SlowQueryInterceptor> _logger;
private const int SlowQueryThresholdMs = 500;
public SlowQueryInterceptor(ILogger<SlowQueryInterceptor> logger)
=> _logger = logger;
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken ct = default)
{
if (eventData.Duration.TotalMilliseconds > SlowQueryThresholdMs)
{
_logger.LogWarning(
"Slow query detected ({DurationMs}ms): {CommandText}",
(int)eventData.Duration.TotalMilliseconds,
command.CommandText);
}
return await base.ReaderExecutedAsync(command, eventData, result, ct);
}
}Registering Interceptors
// Program.cs — register interceptors with DI
builder.Services.AddScoped<AuditInterceptor>();
builder.Services.AddScoped<SoftDeleteInterceptor>();
builder.Services.AddSingleton<SlowQueryInterceptor>();
builder.Services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString);
// Add interceptors from DI (supports constructor injection)
options.AddInterceptors(
sp.GetRequiredService<AuditInterceptor>(),
sp.GetRequiredService<SoftDeleteInterceptor>(),
sp.GetRequiredService<SlowQueryInterceptor>());
});Clinical Audit Log Interceptor
// Records a full audit trail — who changed what field, from what value, to what value
public sealed class ClinicalAuditLogInterceptor : SaveChangesInterceptor
{
private readonly IAuditRepository _auditRepo;
private readonly ICurrentUser _currentUser;
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken ct = default)
{
// Called AFTER successful save — audit entries have correct IDs
var auditEntries = eventData.Context!.ChangeTracker.Entries()
.Where(e => e.Entity is IClinicalEntity &&
e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.Select(e => new AuditLogEntry(
EntityType: e.Entity.GetType().Name,
EntityId: e.Property("Id").CurrentValue?.ToString() ?? "unknown",
Action: e.State.ToString(),
UserId: _currentUser.UserId.ToString(),
Timestamp: DateTime.UtcNow,
Changes: GetChanges(e)))
.ToList();
await _auditRepo.AddRangeAsync(auditEntries, ct);
return result;
}
private static Dictionary<string, (object? Old, object? New)> GetChanges(EntityEntry entry)
=> entry.Properties
.Where(p => p.IsModified)
.ToDictionary(
p => p.Metadata.Name,
p => (p.OriginalValue, p.CurrentValue));
}Production issue I've seen: A team implemented audit logging directly in their repository methods —
await _auditLog.RecordAsync(...)before everySaveChangesAsync(). When a feature called three separate repository methods in one request, it created three audit log entries even if only one SaveChanges was called. Worse, if SaveChanges failed, the audit entry had already been written. UsingSavedChangesAsync()in an interceptor ensures audit logging happens once, after a successful save, with the correct persisted state.
Key Takeaway
Register interceptors via
options.AddInterceptors()with DI support. UseSaveChangesInterceptorfor audit trails and soft delete automation — this keeps these concerns out of repositories and application code. UseDbCommandInterceptorfor slow query detection. Call audit log writes inSavedChangesAsync()(after save) notSavingChangesAsync()(before save) — if the save fails, you do not want a phantom audit entry.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.