DKNet

DKNet.EfCore.Hooks

A before/after SaveChangesAsync hook pipeline for .NET developers adding cross-cutting persistence work.

✨ Why use it?

🚀 Quick Start

Use a .NET 10 console project. Install the package, then replace Program.cs with the example.

dotnet new console -f net10.0 -n EfCoreDemo
cd EfCoreDemo
dotnet add package DKNet.EfCore.Hooks
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12
using Microsoft.EntityFrameworkCore;
using DKNet.EfCore.Hooks;
using Microsoft.Extensions.DependencyInjection;

using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:");
connection.Open();
var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
services.AddLogging();
services.AddDbContextWithHook<DemoDb>(o => o.UseSqlite(connection));
services.AddHook<DemoDb, ReportHook>();
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
await using var db = scope.ServiceProvider.GetRequiredService<DemoDb>();
await db.Database.EnsureCreatedAsync();
db.Add(new Item { Name = "Coffee" });
await db.SaveChangesAsync(); // before: 1, then after: 1

public sealed class DemoDb(DbContextOptions<DemoDb> options) : DbContext(options)
{
    public DbSet<Item> Items => Set<Item>();
}
public sealed class Item
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}
public sealed class ReportHook : HookAsync
{
    public override Task BeforeSaveAsync(DKNet.EfCore.Extensions.Snapshots.SnapshotContext snapshot,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"before: {snapshot.Entities.Count}");
        return Task.CompletedTask;
    }
    public override Task AfterSaveAsync(DKNet.EfCore.Extensions.Snapshots.SnapshotContext snapshot,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"after: {snapshot.Entities.Count}");
        return Task.CompletedTask;
    }
}

Run with dotnet run.

🏗️ Architecture

The DI scope registers a context whose singleton interceptor captures snapshots and invokes scoped hook implementations.

The interceptor is a keyed singleton per registered context type; hook implementations and HookFactory are scoped. A weak-keyed stack holds per-save SnapshotContext instances, including nested saves. Snapshots come from Extensions; hooks execute inside the application process.

🔄 How it works

HookRunnerInterceptor captures changed entries, runs before hooks, lets EF save, then runs after hooks; disabled, empty and failed paths skip work.

Use SaveChangesAsync; synchronous saves fail unless hooks are disabled. Empty snapshots skip hook execution, and failed or cancelled saves clean up the context. Successful after hooks run after EF has saved, which can still be inside a caller-owned transaction.

🧩 Features