.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 around SaveChanges[Async]
  • IDbCommandInterceptor — fires around every SQL command execution
  • IDbConnectionInterceptor — fires around connection open/close

Building an Audit Interceptor

Define marker interfaces on your entities:

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

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

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

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

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

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

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

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

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

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