DKNet

DKNet.EfCore.Hooks

A pluggable before/after-SaveChanges interceptor pipeline for EF Core — one shared interceptor per DbContext type plus a small pair of interfaces you implement.

✨ Why use it?

Reach for this package when you need code to run around change-tracked entities on save. If you only need to intercept SQL statements or connections, use EF Core’s SaveChangesInterceptor/DbCommandInterceptor directly.

🚀 Quick Start

dotnet add package DKNet.EfCore.Hooks

The package depends on DKNet.EfCore.Extensions for SnapshotContext (see DKNet.EfCore.Extensions) and on Microsoft.EntityFrameworkCore.

Minimum wiring — register your DbContext through AddDbContextWithHook instead of AddDbContext, then register hook implementations with AddHook:

using DKNet.EfCore.Hooks;
using Microsoft.Extensions.DependencyInjection;

services.AddDbContextWithHook<AppDbContext>((provider, options) =>
    options.UseSqlServer(connectionString));

services.AddHook<AppDbContext, MyAuditHook>();

AddDbContextWithHook<TDbContext> has two overloads — one taking Action<IServiceProvider, DbContextOptionsBuilder>, one taking Action<DbContextOptionsBuilder<TDbContext>> — both mirroring the standard AddDbContext overloads and internally calling AddHookRunner<TDbContext>() plus options.UseHooks<TDbContext>(provider) for you. If you must register the DbContext yourself (e.g. a base class already calls AddDbContext), call UseHooks<TDbContext>(provider) explicitly inside your own options delegate instead:

services.AddHook<AppDbContext, MyAuditHook>(); // also registers the interceptor for AppDbContext
services.AddDbContext<AppDbContext>((provider, options) =>
{
    options.UseSqlServer(connectionString);
    options.UseHooks<AppDbContext>(provider);
});

AddHookRunner<TDbContext> is internal — you will not call it directly from application code; AddDbContextWithHook and AddHook both call it for you, idempotently, so registration order between them does not matter.

Hooks with no registered HookRunnerInterceptor for their DbContext type are silently never invoked — always register the DbContext via AddDbContextWithHook (or call UseHooks<TDbContext> yourself) or your AddHook<TDbContext, THook>() calls will have no effect.

🧩 Features

The hook interfaces

All hook contracts live in DKNet.EfCore.Hooks.IHook.cs and operate on SnapshotContext from DKNet.EfCore.Extensions.Snapshots (see DKNet.EfCore.Extensions):

public interface IHookBaseAsync; // marker, implement a more specific interface below

public interface IBeforeSaveHookAsync : IHookBaseAsync
{
    Task BeforeSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default);
}

public interface IAfterSaveHookAsync : IHookBaseAsync
{
    Task AfterSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default);
}

public interface IHookAsync : IBeforeSaveHookAsync, IAfterSaveHookAsync;

public abstract class HookAsync : IHookAsync
{
    public virtual Task BeforeSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default) => Task.CompletedTask;
    public virtual Task AfterSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default) => Task.CompletedTask;
}

Implement IBeforeSaveHookAsync for a before-only hook, IAfterSaveHookAsync for an after-only hook, IHookAsync (or inherit HookAsync and override only what you need) for both. SnapshotContext.Entities (an IReadOnlyCollection<SnapshotEntityEntry>) exposes Entity, Entry (the underlying EF Core EntityEntry) and OriginalState for every entry that was Added, Modified, or Deleted at the moment the snapshot was captured — the same snapshot instance is shared by every hook registered on the DbContext, captured once before the before-save hooks run.

Example — a before-save audit stamp hook and an after-save event-publishing hook. The first one is illustrative only: DKNet.EfCore.AuditLogs already ships exactly this behaviour behind services.AddCurrentUserProvider<TDbContext, TProvider>(), which stamps CreatedBy/CreatedOn and UpdatedBy/UpdatedOn from an ICurrentUserProvider without overwriting a value a domain method already recorded — reach for that instead of hand-rolling this, and read it here only as a hook you could write:

using DKNet.EfCore.Hooks;
using DKNet.EfCore.Extensions.Snapshots;
using Microsoft.EntityFrameworkCore;

public sealed class AuditStampHook(ICurrentUserService currentUser) : IBeforeSaveHookAsync
{
    public Task BeforeSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        var now = DateTimeOffset.UtcNow;
        foreach (var entry in context.Entities)
        {
            if (entry.Entity is not IAuditedProperties) continue;

            // IAuditedProperties declares get-only properties on purpose, so write through the
            // tracked entry rather than assigning to the interface.
            if (entry.OriginalState == EntityState.Added)
                entry.Entry.Property(nameof(IAuditedProperties.CreatedBy)).CurrentValue = currentUser.UserId;
            if (entry.OriginalState is EntityState.Added or EntityState.Modified)
                entry.Entry.Property(nameof(IAuditedProperties.UpdatedOn)).CurrentValue = now;
        }

        return Task.CompletedTask;
    }
}

public sealed class DomainEventPublishingHook(IEventPublisher publisher) : IAfterSaveHookAsync
{
    public async Task AfterSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
    {
        foreach (var entry in context.Entities)
        {
            if (entry.Entity is not IEventEntity eventEntity) continue;

            // GetEvents() returns (object[] Events, Type[] EventTypes); the second queue needs an
            // IMapper to materialise, which is what DKNet.EfCore.Events does for you.
            var (events, _) = eventEntity.GetEvents();
            foreach (var domainEvent in events)
                await publisher.PublishAsync(domainEvent, cancellationToken);

            eventEntity.ClearEvents();
        }
    }
}

Register each with the DbContext type it should run for:

services.AddHook<AppDbContext, AuditStampHook>();
services.AddHook<AppDbContext, DomainEventPublishingHook>();

AddHook<TDbContext, THook>() registers THook as AddKeyedScoped, keyed by typeof(TDbContext).FullName, and calling it twice for the same (TDbContext, THook) pair is a no-op (it checks for an existing keyed registration first) — safe to call from multiple independent DI-setup methods. A hook registered for TDbContext also runs for any DbContext subclass of TDbContext, because HookFactory walks the runtime type’s base-type chain when resolving keyed hooks — so hooks registered against a shared base DbContext are inherited by every derived context.

There is no built-in hook ordering: AddHook registers into the DI container’s keyed-service collection, and hooks run in registration order for a given phase. If two hooks must run in a specific relative order, register them in that order (or fold them into a single hook).

Disabling hooks — HookDisablingContext

Data seeding, bulk migrations, or fixups often need to bypass every hook (audit stamping, ownership assignment, event publishing) for a batch of saves. DbContext.DisableHooks() returns an IHookDisablingContext — dispose it (sync or async) to re-enable hooks:

using DKNet.EfCore.Hooks;

await using (db.DisableHooks())
{
    db.Set<Product>().Add(new Product { Name = "Seed data" });
    await db.SaveChangesAsync(); // no hooks run for this save
}

// hooks run normally again from here

The disabling is reference-counted per DbContext CLR type (keyed by Type.FullName), so nested using/await using scopes are safe — hooks stay disabled until the outermost scope disposes. Disabling is scoped by type, not by DbContext instance: while a scope is active, hooks are suppressed for every instance of that DbContext type currently saving, not just the instance the scope was created from — keep disabling scopes short-lived and don’t rely on it for per-instance isolation under concurrent access.

How it runs — HookFactory and HookRunnerInterceptor

You don’t call these directly, but knowing the mechanics helps when hooks don’t seem to fire:

⚙️ Configuration reference

There is no options object for this package — behavior is controlled entirely through what you register:

Aspect Default How to change it
Which DbContext types run hooks None, until registered AddDbContextWithHook<TDbContext>(...) or options.UseHooks<TDbContext>(provider)
Which hooks run for a DbContext None AddHook<TDbContext, THook>(), once per (TDbContext, THook) pair
Hook execution order DI registration order, before-hooks then after-hooks per phase Register hooks in the order you need
Hook lifetime Scoped (AddKeyedScoped) Not configurable — hooks are always scoped to the owning DbContext’s DI scope
HookRunnerInterceptor lifetime Singleton, keyed per DbContext type Not configurable
Disabling hooks Enabled dbContext.DisableHooks() around a using/await using scope

🧱 Where it fits

The interceptor owns two points inside one SaveChangesAsync — before EF Core writes, and after it has written successfully — plus the two exits where your hooks are skipped entirely:

Workflow diagram of one SaveChangesAsync: SavingChangesAsync builds a HookContext that resolves the registered hooks and captures a snapshot, before-save hooks run in DI order, EF Core writes, and SavedChangesAsync runs the after-save hooks. A DisableHooks scope or an empty change set skips both phases, and a failed save disposes the context without running after-save hooks.

DKNet.EfCore.Events, DKNet.EfCore.AuditLogs, and DKNet.EfCore.DataAuthorization are all built as hooks on top of this package, sharing the same HookRunnerInterceptor pipeline and the same SnapshotContext type — verified directly against their internal hook classes:

Because all three register through the same AddHook<TDbContext, THook>() extension against your DbContext, they compose automatically: register your DbContext once with AddDbContextWithHook, then add whichever of AddEventPublisher<TDbContext, TPublisher>(), AddEfCoreAuditLogs<TDbContext, TPublisher>(), and AddDataOwnerProvider<TDbContext, TProvider>() your application needs. A single dbContext.DisableHooks() scope suppresses all of them at once, which is exactly why it’s the recommended way to bypass audit/event/ownership stamping during seeding.

The one place two of them do share state is the audit identity, and they coordinate through a service rather than through this pipeline: EfCoreAuditHook and DataOwnerHook both take DKNet.EfCore.AuditLogs’ optional ICurrentUserProvider, and each decides from its value for the save whether it stamps CreatedBy/UpdatedBy. A non-empty current user means the audit hook stamps them and DataOwnerHook stamps OwnedBy only; no value (or no provider registered) means DataOwnerHook fills them from the ownership key, as it always did. Because the decision comes from the provider and not from who ran first, registration order still does not matter here — but the two hooks are no longer strictly ignorant of each other. See Who fills CreatedBy/UpdatedBy.

⚠️ Gotchas & limits