EF Core Audit Logging with Interceptors that do not Clutter Your DbContext
Learn how to build a clean, production ready audit trail in EF Core that records who changed what and when without stuffing logic into your DbContext. We walk through a practical SaveChanges interceptor, compare it to overriding SaveChanges, and review trusted NuGet options. The post closes with performance and security practices that keep your database fast and your compliance officer calm.
I once woke up to a production bug where every order total became 0 at 3:07 AM. The only thing scarier than that number was the silence in the logs. No trail. No hint. Just wrong. That is when I swore to never ship a system that cannot answer three simple questions: who changed what and when.
In this post we will build a clean audit trail in EF Core using interceptors, compare it to overriding SaveChanges, look at package options, then wrap with performance and security tips that keep your database happy.
The plan
We will record an audit row for each property that changes. That gives you a robust history and avoids schema drift. It looks like this: entity name, primary key, property name, old value, new value, action, user, timestamp. Think of it like a very nosy assistant who never sleeps and writes everything down.
The audit record
We will keep the audit table simple on purpose. You can add JSON columns, correlation ids or IP addresses later.
public class AuditLog{ public int Id { get; set; } public string Entity { get; set; } = ""; public string Key { get; set; } = ""; public string Property { get; set; } = ""; public string? OldValue { get; set; } public string? NewValue { get; set; } public string Action { get; set; } = ""; public string User { get; set; } = ""; public DateTime UtcTime { get; set; }}Getting the current user
Our interceptor will need a user id. In a web app you can use IHttpContextAccessor. In a console or background worker you can return a service account id.
public interface ICurrentUser{ string? Id();}
public sealed class HttpContextCurrentUser : ICurrentUser{ private readonly IHttpContextAccessor _http; public HttpContextCurrentUser(IHttpContextAccessor http) => _http = http; public string? Id() => _http.HttpContext?.User?.Identity?.Name ?? "system";}The interceptor approach
EF Core interceptors let you hook into SaveChanges without stuffing your DbContext with cross cutting logic. They were introduced in EF Core 3 and have matured. We will implement a SaveChangesInterceptor, collect changes from the ChangeTracker, then add AuditLog entities to the same context so they commit in the same transaction.
Before we write code, let’s set two guardrails:
- Exclude AuditLog entities from auditing to avoid infinite mirror selfies
- Only log modified properties to keep the noise down
Building the small pieces first
Let’s start with a helper that turns a property change into a single audit row. Keeping it tiny makes the interceptor code easier to read.
private static AuditLog ToAudit( string entity, string key, string prop, object? oldVal, object? newVal, string action, string user, DateTime when){ return new AuditLog { Entity = entity, Key = key, Property = prop, OldValue = oldVal?.ToString(), NewValue = newVal?.ToString(), Action = action, User = user, UtcTime = when };}Now the heart of it. We iterate tracked entries, compute a composite key, and create rows for each changed property. For fun, we will pretend our database tracks orders from Dunder Mifflin.
public sealed class AuditSaveChangesInterceptor : SaveChangesInterceptor{ private readonly ICurrentUser _user; public AuditSaveChangesInterceptor(ICurrentUser user) => _user = user;
public override InterceptionResult<int> SavingChanges( DbContextEventData data, InterceptionResult<int> result) { var ctx = data.Context; if (ctx is null) return result; var who = _user.Id() ?? "system"; var when = DateTime.UtcNow; var logs = new List<AuditLog>();
foreach (var e in ctx.ChangeTracker.Entries() .Where(x => x.Entity is not AuditLog)) { var key = string.Join( ",", e.Properties.Where(p => p.Metadata.IsPrimaryKey()) .Select(p => p.CurrentValue)); var name = e.Metadata.ClrType.Name;
if (e.State == EntityState.Added) logs.AddRange(e.Properties.Select(p => ToAudit(name, key, p.Metadata.Name, null, p.CurrentValue, "Insert", who, when))); else if (e.State == EntityState.Modified) logs.AddRange(e.Properties.Where(p => p.IsModified).Select(p => ToAudit(name, key, p.Metadata.Name, p.OriginalValue, p.CurrentValue, "Update", who, when))); else if (e.State == EntityState.Deleted) logs.AddRange(e.Properties.Select(p => ToAudit(name, key, p.Metadata.Name, p.OriginalValue, null, "Delete", who, when))); }
if (logs.Count > 0) ctx.AddRange(logs); return base.SavingChanges(data, result); }}Wire it up in ASP.NET Core
We need to register the interceptor and pass it to the DbContext via AddInterceptors. The factory callback gives us the service provider so we can pull the interceptor with the scoped user.
builder.Services.AddHttpContextAccessor();builder.Services.AddScoped<ICurrentUser, HttpContextCurrentUser>();builder.Services.AddScoped<AuditSaveChangesInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, opt) =>{ var aud = sp.GetRequiredService<AuditSaveChangesInterceptor>(); opt.UseSqlServer(builder.Configuration.GetConnectionString("db")); opt.AddInterceptors(aud);});With that in place, any call to SaveChanges or SaveChangesAsync will create AuditLog rows for inserts, updates and deletes across your entities. Michael Scott would be proud. Probably.
The SaveChanges override approach
Overriding SaveChanges in the DbContext can work, but it tangles persistence with cross cutting concerns. It is fine for small apps or where you cannot use interceptors.
Here is a minimal pattern that keeps the loop readable. The BuildAudit method would mirror the logic we used in the interceptor.
public sealed class AppDbContext : DbContext{ private readonly ICurrentUser _user; public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public AppDbContext(DbContextOptions options, ICurrentUser user) : base(options) { _user = user; }
public override int SaveChanges() { var logs = BuildAudit(ChangeTracker, _user.Id() ?? "system"); if (logs.Count > 0) AddRange(logs); return base.SaveChanges(); }}Pros:
- Simple to drop in and reason about
- No extra EF Core concepts to learn
Cons:
- Harder to reuse across contexts
- Risk of creeping complexity as rules grow
- Harder to keep clean boundaries in large solutions
The NuGet package approach
If you want to move fast and let a library handle the heavy lifting, there are solid options:
- Audit.NET with Audit.EntityFramework.Core gives you flexible audit logs with JSON diffs and configuration hooks
- Z.EntityFramework.Plus has an Audit feature along with batch ops and caching
Pros:
- Fast setup, lots of knobs, battle tested
Cons:
- Bigger dependency surface
- You still need to review data security, performance, and storage choices
Performance practices that keep pace
- Filter early: only log Added, Modified, Deleted and only modified properties
- Batch writes: let audit rows save in the same transaction as business rows
- Keep payloads small: do not store large blobs or whole graphs in audit tables
- Use numeric or short string keys to keep indexes and storage lean
- Consider JSON columns for flexibility if your database supports it
- Monitor growth: set retention policies and archive old audit rows
Security and compliance tips that matter
- Always store timestamps in UTC and include the application node name
- Redact or hash sensitive fields like passwords, tokens, SSNs, or secrets
- Encrypt at rest with TDE or column encryption where required
- Include who and where: user id, client id, maybe IP if policy allows
- Treat audit tables as append only for most roles
A tiny end-to-end sanity test
Here is a quick smoke test you can run in a console app with the InMemory provider. It proves the interceptor writes an audit row on update.
using var services = new ServiceCollection() .AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("scranton")) .AddScoped<ICurrentUser>(_ => new FakeUser("dwight"))) .AddScoped<AuditSaveChangesInterceptor>() .BuildServiceProvider();
var db = services.GetRequiredService<AppDbContext>();var order = new { Id = 1, Customer = "Jim", Reams = 5 };// imagine order entity exists; update then save// db.Update(order); db.SaveChanges();// Assert: db.AuditLogs.Any(a => a.User == "dwight")Replace the imaginary update with your real entity. The idea stays the same: change something, save, then inspect AuditLogs.
When to pick which approach
- Use interceptors for clean separation and reuse across contexts
- Override SaveChanges for small apps or migration steps
- Use a package when you need advanced features or a quicker path
Wrap up
Audit trails are not paperwork. They are your safety net, your time machine, and your best friend during those 3 AM incidents. With EF Core interceptors you can keep your DbContext tidy, keep your history complete, and keep your future self from screaming into the void like Kevin after dropping the chili.