Add Cross-Cutting Concerns to EF Core Without Touching Your Repositories

Add Cross-Cutting Concerns to EF Core Without Touching Your Repositories

Author - Abdul Rahman (Bhai)

EFCore

3 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

Open five repositories in a typical EF Core codebase and you'll usually find the same three lines copy-pasted into every single one: a log line before saving, a check for who made the change, and a silent hope that nobody forgets to add it to repository number six. EF Core interceptors let you delete all of that duplication by plugging directly into the DbContext pipeline itself.

An interceptor is just a class that EF Core calls at a specific point in its lifecycle - when a connection opens, when a query compiles, when changes are saved. Register it once, and every DbContext instance in your app picks up the behavior automatically, whether the call came from a repository, a background job, or a minimal API endpoint.

Why we gonna do?

Picture a growing order-management API. Every repository that touches SaveChangesAsync also needs to write an audit row saying what changed and who changed it. So each repository method ends up with a near-identical block: build an audit entry, attach it to the context, save. Twelve repositories, twelve nearly-identical blocks.

Now someone adds repository thirteen and forgets the audit block. Nobody notices for weeks, because the code compiles fine and the happy path works. The gap only shows up when compliance asks for a full history of who deleted a record and that data simply doesn't exist. The same story repeats for connection diagnostics and for guarding against a repository method that silently returns without saving anything - each concern is duplicated everywhere it's needed, and each duplicate is a place it can be forgotten.

EF Core interceptors move that logic out of every repository and into one place that DbContext itself is guaranteed to call, so the behavior can't be skipped by accident.

How we gonna do?

Where interceptors fit in the DbContext lifecycle

A DbContext moves through a predictable lifecycle on every unit of work, and each stage has a matching family of interceptors:


Initialize DbContext  -->  Track Changes  -->  Execute & Save  -->  Dispose
      (Connection)          (Query)            (SaveChanges,          (Connection)
                                                 Command)
            

Because DbContext should be treated as a short-lived unit of work rather than a long-running singleton, most interceptors are registered with the same scoped lifetime as the context. A few - the ones that don't depend on any single context instance - must be registered as singletons instead, and EF Core marks those with the ISingletonInterceptor interface. We'll call that out for each example below.

Log connection activity with a DbConnectionInterceptor

The simplest interceptor watches the raw ADO.NET connection underneath DbContext - when it's created, opened, closed, and disposed. This is useful for diagnosing connection-pool exhaustion or unexpected reconnects without adding logging calls anywhere near your business logic.


public class OrderConnectionDiagnosticsInterceptor(
    ILogger<OrderConnectionDiagnosticsInterceptor> logger) : DbConnectionInterceptor
{
    public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
    {
        logger.LogInformation("Connection created for {DataSource}", result.DataSource);
        return result;
    }

    public override async Task ConnectionClosedAsync(DbConnection connection, ConnectionEndEventData eventData)
    {
        logger.LogInformation("Connection closed after {Duration}", eventData.Duration);
        return await base.ConnectionClosedAsync(connection, eventData);
    }

    public override async Task ConnectionDisposedAsync(DbConnection connection, ConnectionEndEventData eventData)
    {
        logger.LogInformation("Connection disposed");
        return await base.ConnectionDisposedAsync(connection, eventData);
    }
}
            

DbConnectionInterceptor doesn't implement ISingletonInterceptor, so it's safe - and correct - to register it with the same scoped lifetime as your DbContext.

Cap unbounded result sets with a query expression interceptor

A repository method like GetAllOrdersAsync looks harmless in development with a handful of test rows. In production, with three years of order history, that same method can pull hundreds of thousands of rows into memory. A query expression interceptor lets you rewrite the query itself before EF Core compiles it, so every query gets a safety net automatically.


public class MaxRowLimitInterceptor : IQueryExpressionInterceptor
{
    private const int DefaultRowLimit = 200;

    private static readonly MethodInfo TakeMethodInfo = typeof(Queryable)
        .GetMethods()
        .Where(m => m.Name == nameof(Queryable.Take))
        .First();

    public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
    {
        var entityType = queryExpression.Type.GetGenericArguments().FirstOrDefault();
        if (entityType is null)
        {
            return queryExpression;
        }

        var genericTakeMethod = TakeMethodInfo.MakeGenericMethod(entityType);
        return Expression.Call(genericTakeMethod, queryExpression, Expression.Constant(DefaultRowLimit));
    }
}
            

This one derives from ISingletonInterceptor, because it's stateless and relies on reflection metadata that's expensive to rebuild - register it once as a singleton, and EF Core reuses the same instance for every context.

Guard against silent no-op saves with a SaveChangesInterceptor

SaveChangesAsync returns an integer: the number of rows written. A repository method that calls it after a bug wipes out its own change tracking will still "succeed" while writing zero rows - and nothing about that looks like a failure unless you're specifically checking for it.


public class NoOpSaveGuardInterceptor(ILogger<NoOpSaveGuardInterceptor> logger) : SaveChangesInterceptor
{
    public override async ValueTask<int> SavedChangesAsync(
        SaveChangesCompletedEventData eventData,
        int result,
        CancellationToken cancellationToken = default)
    {
        if (result < 1)
        {
            logger.LogWarning("SaveChangesAsync completed but modified zero rows");
        }

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

Write audit rows automatically with the same interceptor type

Because a SaveChangesInterceptor sees the change tracker right before it saves, it's also the right place to generate audit rows for every insert, update, and delete - without a single repository method knowing that auditing exists. Thread.CurrentPrincipal below exposes the current user's name, and AuditEntry is a plain entity with EntityName, Action, ChangedBy, and ChangedOnUtc properties.


public class AuditTrailInterceptor() : SaveChangesInterceptor
{
    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is not null)
        {
            var auditEntries = BuildAuditEntries(eventData.Context.ChangeTracker);
            await eventData.Context.Set<AuditEntry>().AddRangeAsync(auditEntries, cancellationToken);
        }

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

    private List<AuditEntry> BuildAuditEntries(ChangeTracker changeTracker) =>
        [.. changeTracker.Entries()
            .Where(entry => entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
            .Select(entry => new AuditEntry
            {
                EntityName = entry.Entity.GetType().Name,
                Action = entry.State.ToString(),
                ChangedBy = Thread.CurrentPrincipal?.Identity?.Name ?? "User Unknown",
                ChangedOnUtc = DateTime.UtcNow
            })];
}
            

Notice there's no explicit call to SaveChangesAsync here - the audit rows just get added to the same change tracker, so they're written in the very same transaction as the data change they're describing.

Register every interceptor and get the lifetime right

Add each interceptor to dependency injection, then pull them all into the context through IEnumerable<IInterceptor> and wire them up in OnConfiguring:


// Program.cs
builder.Services.AddScoped<IInterceptor, OrderConnectionDiagnosticsInterceptor>();
builder.Services.AddScoped<IInterceptor, NoOpSaveGuardInterceptor>();
builder.Services.AddScoped<IInterceptor, AuditTrailInterceptor>();
builder.Services.AddSingleton<IInterceptor, MaxRowLimitInterceptor>();

// OrderContext.cs
public class OrderContext(
    DbContextOptions<OrderContext> options,
    IEnumerable<IInterceptor> interceptors) : DbContext(options)
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
        optionsBuilder.AddInterceptors(interceptors);
}
            

Every repository that injects OrderContext now gets connection diagnostics, a query row cap, zero-row-save warnings, and a full audit trail - without a single one of those repositories knowing any of these interceptors exist.

Summary

Key takeaways from this article:

  • Interceptors plug into specific stages of the DbContext lifecycle - connection, query, and save - so cross-cutting logic lives in one place instead of every repository
  • A DbConnectionInterceptor gives you visibility into connection open/close/dispose events for diagnostics
  • An IQueryExpressionInterceptor can rewrite a query's expression tree before it compiles - useful for guardrails like row limits
  • A SaveChangesInterceptor can both validate the result of a save and generate audit rows in the same transaction
  • Match the interceptor's lifetime to whether it implements ISingletonInterceptor - most are scoped, a few must be singletons

What to read next: now that cross-cutting concerns are handled, learn how to enforce schema standards automatically in Enforce Database Schema Standards Automatically With EF Core Conventions, or jump ahead to Catch Slow Queries and Rewrite Them on the Fly With EF Core Command Interceptors to see interceptors that operate on the raw SQL EF Core sends.

  • Efcore
  • EF Core
  • Entity Framework Core
  • Interceptors
  • DbContext
  • Auditing
  • Connection Interceptor
  • SaveChanges Interceptor
  • .NET