Skip to content

EF Core Interceptors

SimpleModule supports EF Core SaveChangesInterceptor for cross-cutting concerns like audit logging, soft deletes, and timestamp management. The source generator auto-discovers interceptors and wires them into the DbContext pipeline.

Shipped Interceptors

The framework ships three interceptors under framework/SimpleModule.Database/Interceptors/, all auto-registered:

InterceptorPurpose
EntityInterceptorPopulates CreatedAt/UpdatedAt, audit user fields, concurrency stamps, versioning, tenant IDs, and converts hard deletes into soft deletes based on the interfaces an entity implements (IHasCreationTime, IHasModificationTime, IAuditable, IHasConcurrencyStamp, IVersioned, IMultiTenant, ISoftDelete).
DomainEventInterceptorCollects events from IHasDomainEvents entities before save and dispatches them via Wolverine's IMessageBus after a successful save.
EntityChangeInterceptorCaptures entity changes before save and dispatches them to typed IEntityChangeHandler<T> implementations after save.

Overview

A SaveChangesInterceptor hooks into the EF Core save pipeline, allowing you to inspect or modify entities before or after they are persisted. Common use cases include:

  • Setting CreatedAt/UpdatedAt timestamps
  • Recording audit log entries
  • Enforcing business rules before save
  • Publishing domain events after save

The Circular Dependency Problem

When an interceptor depends on a service that itself depends on a DbContext, you get a circular dependency that causes a deadlock during DI construction:

SaveChangesInterceptor
    → ISettingsContracts (constructor injection)
        → SettingsService
            → SettingsDbContext (deadlock!)

This happens because:

  1. EF Core resolves all registered ISaveChangesInterceptor instances during DbContext options construction
  2. If an interceptor's constructor requires a service that transitively depends on a DbContext, the DI container tries to build the DbContext to satisfy the service, which tries to build the interceptors, which tries to build the service...

The Solution: Lazy Resolution

Inject IServiceProvider and resolve dependencies at interception time -- not at construction time. The framework's own interceptors do exactly this: DomainEventInterceptor and EntityChangeInterceptor take a non-optional IServiceProvider and resolve services (like Wolverine's IMessageBus or typed change handlers) inside SavingChangesAsync/SavedChangesAsync. EntityInterceptor takes IHttpContextAccessor and an optional ITenantContext? directly — both are safe because they don't depend on a DbContext.

Correct Pattern

csharp
public sealed class AuditInterceptor(
    IServiceProvider? serviceProvider = null
) : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        // Resolve at interception time -- safe, no circular dependency
        var settings = serviceProvider?.GetService<ISettingsContracts>();
        if (settings is not null)
        {
            // Use the service
        }

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

Anti-Pattern

csharp
// WRONG: Causes circular dependency during DI construction
public sealed class BadInterceptor(
    ISettingsContracts settings  // Don't do this!
) : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        // settings was injected in constructor -- this triggers circular dependency
        var value = settings.GetValue("key");
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}

Guidelines

Constructor Parameters

  • Never inject services that transitively depend on a DbContext into the interceptor constructor — that's what causes the deadlock
  • Do inject IServiceProvider (optional via IServiceProvider? = null if you want the interceptor to work without DI in unit tests) when runtime service resolution is needed
  • Do inject simple services (like ILogger<T>, TimeProvider, IHttpContextAccessor) that have no DbContext dependency — the framework's EntityInterceptor does this with IHttpContextAccessor and ITenantContext?

The SM0039 diagnostic flags interceptors whose constructor parameters transitively depend on a DbContext, so you'll see a compile-time warning before you hit the runtime deadlock.

Service Resolution Timing

  • Only resolve services within interception methods: SavingChangesAsync, SavedChangesAsync, or SaveChangesFailedAsync
  • The framework resolves all registered ISaveChangesInterceptor instances lazily during DbContext options construction

Null Safety

Making IServiceProvider? optional (with = null) ensures the interceptor works in unit tests where DI may not be available:

csharp
public sealed class TimestampInterceptor(
    IServiceProvider? serviceProvider = null
) : 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);

        var now = serviceProvider?.GetService<TimeProvider>()?.GetUtcNow()
            ?? DateTimeOffset.UtcNow;

        foreach (var entry in eventData.Context.ChangeTracker.Entries())
        {
            if (entry.State == EntityState.Added && entry.Entity is IHasCreationTime created)
            {
                created.CreatedAt = now;
            }

            if (entry.State == EntityState.Modified && entry.Entity is IHasModificationTime updated)
            {
                updated.UpdatedAt = now;
            }
        }

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

Auto-Discovery

The source generator automatically discovers classes that implement SaveChangesInterceptor (or ISaveChangesInterceptor) in module assemblies. Discovered interceptors are registered in the generated DbContext configuration -- you do not need to manually register them.

The generator records each interceptor's constructor parameters to ensure correct DI wiring:

csharp
// From DiscoveryData
internal readonly record struct InterceptorInfoRecord(
    string FullyQualifiedName,
    string ModuleName,
    ImmutableArray<string> ConstructorParamTypeFqns
);

Summary

DoDon't
Inject IServiceProvider? as optionalInject services that depend on DbContext
Resolve services inside interception methodsResolve services in the constructor
Use ?.GetService<T>() for null safetyAssume services are always available
Keep constructors minimalAdd complex initialization logic to constructors

Next Steps

Released under the MIT License.