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):
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:
ActionHistoryInterceptor— Must run first to see originalDeletedstate before soft delete converts itSoftDeleteInterceptor— ConvertsDelete→ModifiedwithIsDeleted = trueAuditableEntityInterceptor— Sets timestamps and user IDs
Global Query Filters¶
Combined with interceptors, query filters ensure deleted entities are excluded:
Override with IgnoreQueryFilters() when needed.
Related¶
- Soft Delete — Soft delete pattern
- Concurrency — Optimistic locking
- Data Model — Entity design