Skip to content

Interceptors Pattern

EF Core SaveChanges interceptors for cross-cutting entity concerns.

Overview

flowchart TB
    A[SaveChangesAsync] --> B[AuditableInterceptor]
    B --> C[SoftDeleteInterceptor]
    C --> D[Database]

    B -.->|Sets| E[CreatedDate, CreatedByUserId]
    B -.->|Sets| F[UpdatedDate, UpdatedByUserId]
    C -.->|Converts| G[Delete → Update IsDeleted]

Auditable Interceptor

Automatically populates audit fields on entities extending AuditableEntity.

public sealed class AuditableEntityInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserAccessor _currentUserAccessor;
    private readonly IDateTimeProvider _dateTimeProvider;

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(...)
    {
        var now = _dateTimeProvider.GetDateTime();
        var userId = _currentUserAccessor.UserId;

        foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.CreatedByUserId ??= userId;
                if (entry.Entity.CreatedDate == default)
                {
                    entry.Entity.CreatedDate = now;
                }
            }

            if (entry.State == EntityState.Modified)
            {
                entry.Entity.UpdatedByUserId = userId;
                entry.Entity.UpdatedDate = now;
            }
        }

        return base.SavingChangesAsync(...);
    }
}

Soft Delete Interceptor

Converts Delete operations to Update for soft-deletable entities.

public sealed class SoftDeleteInterceptor : SaveChangesInterceptor
{
    private readonly IDateTimeProvider _dateTimeProvider;

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(...)
    {
        var now = _dateTimeProvider.GetDateTime();

        foreach (var entry in context.ChangeTracker.Entries<ISoftDeletable>())
        {
            if (entry.State == EntityState.Deleted)
            {
                entry.State = EntityState.Modified;
                entry.Entity.IsDeleted = true;
                entry.Entity.DeletedDate = now;
            }
        }

        return base.SavingChangesAsync(...);
    }
}

Registration

services.AddDbContext<DataContext>((sp, options) =>
{
    options.AddInterceptors(
        sp.GetRequiredService<AuditableEntityInterceptor>(),
        sp.GetRequiredService<SoftDeleteInterceptor>()
    );
});

Execution Order

Interceptors execute in registration order:

  1. AuditableEntityInterceptor — Sets timestamps and user IDs
  2. SoftDeleteInterceptor — Converts deletes

Global Query Filters

Combined with interceptors, query filters ensure deleted entities are excluded:

modelBuilder.Entity<Marker>()
    .HasQueryFilter(m => !m.IsDeleted);

Override with IgnoreQueryFilters() when needed.