← Back to all posts

EF Core Interceptors in .NET

EF Core Interceptors in .NET

Why EF Core Interceptors Matter

Most applications eventually need behavior that wraps database operations without cluttering every repository or service.

Examples:

  • auditing CreatedOnUtc and ModifiedOnUtc
  • writing Outbox messages automatically
  • logging slow queries
  • enforcing multi-tenant rules
  • inspecting SQL commands before execution

A naive approach usually spreads this logic across services, repositories, or DbContext methods. That works at first, but it creates duplication and inconsistency.

EF Core interceptors provide a cleaner alternative.

What Are EF Core Interceptors?

Interceptors let you plug into EF Core’s internal pipeline and react to important events such as:

  • saving changes
  • executing commands
  • opening connections
  • handling transactions
  • materializing entities

They are a powerful way to implement cross-cutting persistence behavior in one place.

Interceptors vs Overriding SaveChanges

A lot of teams start here:

public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    // custom logic
    return base.SaveChangesAsync(cancellationToken);
}

This is sometimes fine, but interceptors are usually a better fit when:

  • you want reusable behavior across multiple contexts
  • you want better separation of concerns
  • you need to intercept more than just SaveChanges
  • you want to compose multiple behaviors cleanly

Think of overrides as local customization and interceptors as pipeline-level extensibility.

Common Interceptor Types

Some of the most useful EF Core interceptors are:

  • SaveChangesInterceptor
  • DbCommandInterceptor
  • DbConnectionInterceptor
  • DbTransactionInterceptor
  • IMaterializationInterceptor

For most business applications, SaveChangesInterceptor and DbCommandInterceptor are the most common starting points.

1. SaveChangesInterceptor for Auditing

A classic use case is automatically updating audit fields.

Auditable entity contract

public interface IAuditableEntity
{
    DateTime CreatedOnUtc { get; set; }
    DateTime? ModifiedOnUtc { get; set; }
}

Interceptor implementation

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

public sealed class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var dbContext = eventData.Context;
        if (dbContext is null)
        {
            return base.SavingChangesAsync(eventData, result, cancellationToken);
        }

        var utcNow = DateTime.UtcNow;

        foreach (var entry in dbContext.ChangeTracker.Entries<IAuditableEntity>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.CreatedOnUtc = utcNow;
            }

            if (entry.State == EntityState.Modified)
            {
                entry.Entity.ModifiedOnUtc = utcNow;
            }
        }

        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}

This keeps audit behavior out of your services and repositories.

Registering Interceptors

You typically register interceptors when configuring your DbContext:

builder.Services.AddSingleton<AuditSaveChangesInterceptor>();

builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseSqlServer(connectionString);
    options.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>());
});

This pattern works well because interceptors become part of the DI graph.

2. SaveChangesInterceptor for the Outbox Pattern

Interceptors are also a strong fit for writing Outbox rows from domain events.

Instead of doing this manually in every command handler, a SaveChangesInterceptor can:

  1. inspect tracked aggregates
  2. pull domain events from them
  3. serialize those events into OutboxMessage rows
  4. let EF save everything in one transaction

Example shape

public interface IHasDomainEvents
{
    IReadOnlyCollection<IDomainEvent> DomainEvents { get; }
    void ClearDomainEvents();
}
public sealed class OutboxSaveChangesInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var dbContext = eventData.Context;
        if (dbContext is null)
        {
            return base.SavingChangesAsync(eventData, result, cancellationToken);
        }

        var domainEventEntries = dbContext.ChangeTracker
            .Entries<IHasDomainEvents>()
            .Where(x => x.Entity.DomainEvents.Count > 0)
            .ToList();

        var outboxMessages = new List<OutboxMessage>();

        foreach (var entry in domainEventEntries)
        {
            foreach (var domainEvent in entry.Entity.DomainEvents)
            {
                outboxMessages.Add(new OutboxMessage
                {
                    Id = Guid.NewGuid(),
                    OccurredOnUtc = DateTime.UtcNow,
                    Type = domainEvent.GetType().Name,
                    Payload = JsonSerializer.Serialize(domainEvent)
                });
            }

            entry.Entity.ClearDomainEvents();
        }

        if (outboxMessages.Count > 0)
        {
            dbContext.Set<OutboxMessage>().AddRange(outboxMessages);
        }

        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}

This is one of the best real-world examples of interceptor value.

3. DbCommandInterceptor for Query Monitoring

Sometimes you want insight into SQL execution itself.

Use case

  • detect slow queries
  • log suspicious SQL
  • add telemetry
  • inspect command text and parameters

Example

using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;

public sealed class SlowQueryInterceptor : DbCommandInterceptor
{
    private readonly ILogger<SlowQueryInterceptor> _logger;

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

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

        return await base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
    }
}

This is useful when you need visibility without modifying every query call site.

4. Connection and Transaction Interceptors

These are less common in application code, but still useful.

Typical scenarios:

  • add diagnostics when connections open/close
  • track transaction start/commit/rollback
  • integrate with observability platforms
  • capture infrastructure-level telemetry

These are usually more relevant in mature systems with strong operational monitoring requirements.

5. Materialization Interceptors

IMaterializationInterceptor allows you to run logic when EF creates entity instances from query results.

Use cases can include:

  • lightweight initialization logic
  • enforcing internal invariants after hydration
  • attaching metadata that should not live in constructors

This is an advanced tool and should be used carefully. If overused, it can make object lifecycle harder to understand.

When Interceptors Are a Good Fit

Interceptors are valuable when the behavior is:

  • cross-cutting
  • persistence-related
  • repeatable across many operations
  • awkward to enforce manually everywhere

Examples that fit well:

  • auditing
  • Outbox writes
  • SQL timing/logging
  • soft-delete transformations
  • tenant guards

When Interceptors Are a Bad Fit

Avoid interceptors when the logic is:

  • business-specific and not generic
  • hard to understand outside the request flow
  • likely to surprise future maintainers

If the behavior changes the business meaning of an operation in a non-obvious way, it may belong in an explicit service or domain workflow instead.

Common Mistakes

1. Hiding too much behavior

If developers cannot explain what happens during SaveChanges, you may have over-abstracted.

2. Mixing business rules into infrastructure interception

Interceptors should usually stay focused on persistence/infrastructure concerns.

3. Registering too many overlapping interceptors

Multiple interceptors are fine, but they should remain predictable and composable.

4. Forgetting performance implications

Interceptors run often. Avoid heavy reflection, expensive serialization, or unnecessary allocations in hot paths.

Practical Recommendation

A strong default approach is:

  • use SaveChangesInterceptor for audit and Outbox scenarios
  • use DbCommandInterceptor for telemetry and slow query logging
  • keep the logic simple and explicit
  • document the active interceptors in the data layer

That gives you most of the benefit without turning EF Core into a hidden magic engine.

Summary

EF Core interceptors are one of the most useful extension points in the persistence stack.

They help centralize concerns like:

  • auditing
  • Outbox writing
  • SQL monitoring
  • infrastructure telemetry

Used well, they reduce duplication and keep data-access behavior consistent. Used poorly, they make persistence harder to reason about.

The right approach is pragmatic: use interceptors for infrastructure concerns that truly benefit from being centralized, and keep business workflows explicit.