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";
}