Skip to content

Interceptors Pattern

EF Core SaveChanges interceptors for cross-cutting entity concerns.

Overview

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

    B -.->|Records| F[ActionHistory entries]
    C -.->|Converts| G[Delete → Update IsDeleted]
    D -.->|Sets| H[CreatedDate, UpdatedDate]

Action History Interceptor

Records change history for tracked entities. Captures created, updated, deleted, and restored actions.

public sealed class ActionHistoryInterceptor : SaveChangesInterceptor
{
    private readonly ActionHistoryRegistry _registry;
    private readonly ICurrentUserAccessor _currentUserAccessor;
    private readonly IDateTimeProvider _dateTimeProvider;
    private readonly ICorrelationContext _correlationContext;

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(...)
    {
        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (!_registry.TryGetMapping(entry.Entity.GetType(), out var mapping))
                continue;

            var action = DetermineAction(entry); // Created/Updated/Deleted/Restored
            if (action is null) continue;

            context.Set<ActionHistory>().Add(new ActionHistory
            {
                GroupId = GetGroupId(entry),
                ActorUserId = _currentUserAccessor.UserId,
                Action = action.Value,
                EntityType = mapping.EntityType,
                EntityId = GetEntityId(entry, mapping),
                Timestamp = _dateTimeProvider.GetDateTime(),
                CorrelationId = _correlationContext.CorrelationId,
                Metadata = BuildMetadata(entry, action.Value) // Changed fields for updates
            });
        }
        return base.SavingChangesAsync(...);
    }
}

Tracked Entities

Configured via ActionHistoryRegistryBuilder:

var registry = new ActionHistoryRegistryBuilder()
    .Track<Marker>(TrackedEntityType.Marker, m => m.MarkerId)
    .Track<Transaction>(TrackedEntityType.Transaction, t => t.TransactionId)
    .Track<Note>(TrackedEntityType.Note, n => n.NoteId)
    .Track<Category>(TrackedEntityType.Category, c => c.CategoryId)
    .Track<RecurringTransaction>(TrackedEntityType.RecurringTransaction, r => r.RecurringTransactionId)
    .Track<NoteItem>(TrackedEntityType.NoteItem, n => n.NoteItemId)
    .Track<BudgetAccount>(TrackedEntityType.BudgetAccount, b => b.BudgetAccountId)
    .Build();

Action Types

Action Trigger
Created Entity added
Updated Entity modified (real changes only, ignores audit fields)
Deleted Entity deleted or soft-deleted (IsDeleted set to true)
Restored Soft-deleted entity restored (IsDeleted set to false)

Metadata

For Updated actions, stores the list of changed field names (excluding audit and soft-delete fields):

{"fieldsChanged": ["Title", "Rating"]}

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

// Action history tracking configuration
services.AddActionHistoryTracking(); // Registers ActionHistoryRegistry

// Interceptor registration (order matters!)
services.AddDbContext<DataContext>((sp, options) =>
{
    options.AddInterceptors(
        sp.GetRequiredService<ActionHistoryInterceptor>(),
        sp.GetRequiredService<SoftDeleteInterceptor>(),
        sp.GetRequiredService<AuditableEntityInterceptor>()
    );
});

Execution Order

Interceptors execute in registration order. Order is critical:

  1. ActionHistoryInterceptor — Must run first to see original Deleted state before soft delete converts it
  2. SoftDeleteInterceptor — Converts DeleteModified with IsDeleted = true
  3. AuditableEntityInterceptor — Sets timestamps and user IDs

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.