A before/after SaveChangesAsync hook pipeline for .NET developers adding cross-cutting persistence work.
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.
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.
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.
HookAsync to inspect the shared snapshot around a save.DisableHooks() scope when a batch must bypass every hook for that context type.