Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20264 min read
EF CoreInterceptorsAuditASP.NET Core.NETCross-Cutting
Share:𝕏

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 context

Audit Interceptor

C#
// 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

C#
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

C#
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

C#
// 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

C#
// 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 every SaveChangesAsync(). 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. Using SavedChangesAsync() 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. Use SaveChangesInterceptor for audit trails and soft delete automation — this keeps these concerns out of repositories and application code. Use DbCommandInterceptor for slow query detection. Call audit log writes in SavedChangesAsync() (after save) not SavingChangesAsync() (before save) — if the save fails, you do not want a phantom audit entry.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.