EF Core Interceptors and Diagnostics: Hooking Into the Pipeline

EF Core interceptors let you hook into database operations — before a command executes, after it completes, when a connection opens, or when a transaction commits. They're the right tool for cross-cutting concerns like audit logging, query hints, soft deletes, and performance monitoring.

Types of Interceptors

EF Core provides several interceptor base classes:

Each provides virtual methods for before/after (synchronous and asynchronous) variants.

A Simple Command Interceptor

Log slow queries by intercepting command execution:

SlowQueryInterceptor.cs
public class SlowQueryInterceptor : DbCommandInterceptor
{
    private readonly ILogger<SlowQueryInterceptor> _logger;
    private readonly TimeSpan _threshold = TimeSpan.FromMilliseconds(500);

    public SlowQueryInterceptor(ILogger<SlowQueryInterceptor> logger)
    {
        _logger = logger;
    }

    public override DbDataReader ReaderExecuted(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result)
    {
        if (eventData.Duration > _threshold)
        {
            _logger.LogWarning(
                "Slow query ({Duration}ms): {CommandText}",
                eventData.Duration.TotalMilliseconds,
                command.CommandText);
        }

        return result;
    }

    public override ValueTask<DbDataReader> ReaderExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Duration > _threshold)
        {
            _logger.LogWarning(
                "Slow query ({Duration}ms): {CommandText}",
                eventData.Duration.TotalMilliseconds,
                command.CommandText);
        }

        return ValueTask.FromResult(result);
    }
}

Registering Interceptors

Add interceptors when configuring your DbContext:

Example.cs
services.AddDbContext<AppDbContext>((serviceProvider, options) =>
    options.UseSqlServer(connectionString)
           .AddInterceptors(
               serviceProvider.GetRequiredService<SlowQueryInterceptor>()));

services.AddSingleton<SlowQueryInterceptor>();

Or directly in OnConfiguring:

Example.cs
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.AddInterceptors(new SlowQueryInterceptor(logger));
}

Modifying Commands Before Execution

Interceptors can modify SQL before it reaches the database. For example, adding a query hint:

NoLockInterceptor.cs
public class NoLockInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        command.CommandText = command.CommandText
            .Replace("FROM [Products]", "FROM [Products] WITH (NOLOCK)");

        return result;
    }
}

Use this with caution — modifying SQL text is fragile. Prefer EF Core's built-in mechanisms where possible.

SaveChanges Interceptor for Auditing

A SaveChangesInterceptor is ideal for automatically populating audit fields:

AuditInterceptor.cs
public class AuditInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is null) return ValueTask.FromResult(result);

        var now = DateTime.UtcNow;

        foreach (var entry in eventData.Context.ChangeTracker.Entries<IAuditable>())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.Entity.CreatedAt = now;
                    entry.Entity.UpdatedAt = now;
                    break;
                case EntityState.Modified:
                    entry.Entity.UpdatedAt = now;
                    break;
            }
        }

        return ValueTask.FromResult(result);
    }
}

public interface IAuditable
{
    DateTime CreatedAt { get; set; }
    DateTime UpdatedAt { get; set; }
}

Soft Delete with SaveChanges Interceptor

Automatically convert hard deletes to soft deletes:

SoftDeleteInterceptor.cs
public class SoftDeleteInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is null) return ValueTask.FromResult(result);

        foreach (var entry in eventData.Context.ChangeTracker.Entries<ISoftDeletable>())
        {
            if (entry.State == EntityState.Deleted)
            {
                entry.State = EntityState.Modified;
                entry.Entity.IsDeleted = true;
                entry.Entity.DeletedAt = DateTime.UtcNow;
            }
        }

        return ValueTask.FromResult(result);
    }
}

Connection Interceptors

Monitor connection lifecycle:

ConnectionMonitorInterceptor.cs
public class ConnectionMonitorInterceptor : DbConnectionInterceptor
{
    private readonly ILogger<ConnectionMonitorInterceptor> _logger;

    public ConnectionMonitorInterceptor(ILogger<ConnectionMonitorInterceptor> logger)
    {
        _logger = logger;
    }

    public override void ConnectionOpened(
        DbConnection connection,
        ConnectionEndEventData eventData)
    {
        _logger.LogDebug(
            "Connection opened to {Database} ({Duration}ms)",
            connection.Database,
            eventData.Duration.TotalMilliseconds);
    }
}

Diagnostic Listeners

For lower-level diagnostics, EF Core emits events through DiagnosticSource. This integrates with .NET's standard diagnostics infrastructure:

EfCoreDiagnosticListener.cs
public class EfCoreDiagnosticListener : IObserver<DiagnosticListener>
{
    public void OnNext(DiagnosticListener listener)
    {
        if (listener.Name == DbLoggerCategory.Name)
        {
            listener.Subscribe(new EfCoreEventObserver());
        }
    }

    public void OnError(Exception error) { }
    public void OnCompleted() { }
}

For most applications, interceptors are simpler and more practical than diagnostic listeners. Use diagnostic listeners when you need integration with distributed tracing systems like OpenTelemetry.

Practical Tips

Interceptors provide a clean, testable way to add cross-cutting behaviour to your data access layer without scattering it across your application code.