AuditEF CoreAugust 2026 · 6 min read

Audit Trail Implementation with EF Core: Interceptors That Do the Work

TL;DR

One SaveChangesInterceptor can audit every insert, update, and delete in your app. It reads the EF Core ChangeTracker, captures before and after values, resolves the current user, and writes audit rows in the same transaction.

Audit Trail Implementation with EF Core changes the shape of the problem. Instead of writing logging code in every handler, endpoint, and background job, you register one interceptor and EF Core does the rest. SaveChangesInterceptor — and its async sibling SavingChangesAsync — gives you a single hook that fires every time the app saves changes, with full access to the ChangeTracker and its original and current values. The result is complete coverage with almost no code touching business logic.

This article builds that interceptor end to end: the lifecycle hooks, reading the ChangeTracker for before-and-after values, resolving the current user from the HTTP context, and wiring everything through dependency injection in a .NET 10 application.

Audit Trail Implementation with EF Core: Why Interceptors?

Teams usually attempt audit logging three ways, and two of them fail at scale. The first approach — manually writing an AuditLog row inside every handler — is correct but unmaintainable: every new feature must remember to log, and one missed call means a silent gap in the record. The second approach — database triggers — captures everything but loses the user identity, the before-and-after values in a useful shape, and the application's own semantics. The third approach, EF Core interceptors, sits in the middle and gets the best of both worlds.

  • Single point of control — the audit logic lives in one class, not in every feature
  • Automatic coverage — new features are audited with zero extra code
  • Atomic writes — the audit row commits in the same transaction as the change
  • Full change data — the ChangeTracker exposes original and current values before the save

In Indotalent's architecture, the interceptor lives in the shared infrastructure layer and every vertical slice automatically inherits it. Adding a new feature never requires thinking about auditing.

The Audit Trail Implementation Pipeline: How the Interceptor Lifecycle Works

EF Core fires the interceptor at a well-defined point in the save pipeline. In SavingChangesAsync, the ChangeTracker already knows every entity being inserted, updated, or deleted — so you can inspect the values before the database touches them, and you can add the audit rows to the same context so they commit atomically:

public sealed class AuditInterceptor : SaveChangesInterceptor
{
    private readonly AppDbContext _db;
    private readonly IHttpContextAccessor _http;

    public AuditInterceptor(AppDbContext db, IHttpContextAccessor http)
    {
        _db = db;
        _http = http;
    }

    public override async ValueTask<int> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var context = eventData.Context;
        if (context is not AppDbContext appDb)
            return await base.SavingChangesAsync(eventData, result, cancellationToken);

        var userId = _http.HttpContext?.User
            .FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";

        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (entry.State is not (EntityState.Added
                or EntityState.Modified or EntityState.Deleted))
                continue;

            appDb.AuditLogs.Add(new AuditLog
            {
                EntityName = entry.Metadata.Name,
                EntityId = entry.Property("Id").CurrentValue?.ToString() ?? "",
                Action = entry.State.ToString(),
                UserId = userId,
                ChangedAt = DateTimeOffset.UtcNow,
                OldValuesJson = ToJson(entry, isNew: false),
                NewValuesJson = ToJson(entry, isNew: true)
            });
        }

        return await base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}

The pattern is simple: enumerate the tracked entries, filter out unchanged rows, read the user id from the current HTTP context, and append one AuditLog row per changed entity. Because the audit rows are added to the same DbContext, EF Core writes them in the same transaction. There is no window where a change exists without its audit record.

Diff the Values and Handle New and Deleted Rows

Reading before-and-after values is where interceptors shine. For an Added entry, there are no original values; for a Deleted entry, there are no current values. For a Modified entry, you want only the properties whose IsModified flag is true, otherwise you serialize the whole row on every save. A small helper keeps the logic tidy:

static string? ToJson(EntityEntry entry, bool isNew)
{
    var changed = new Dictionary<string, object?>();
    var props = entry.Metadata.GetProperties();

    foreach (var prop in props)
    {
        if (!isNew && !entry.IsModified(prop.Name))
            continue;

        var value = entry.Property(prop.Name).CurrentValue;
        if (value is null) continue;
        changed[prop.Name] = value;
    }

    return changed.Count == 0 ? null : JsonSerializer.Serialize(changed);
}

Store the serialized snapshots as JSON in the audit row. Later, an admin dashboard or report can deserialize them and render "discount changed from 10% to 15%" without ever touching the entity's current state. Because the JSON is a snapshot, it survives schema changes and deleted columns.

Wire the Interceptor Through Dependency Injection

The interceptor needs scoped services — the DbContext and the HTTP context — so it must be registered as scoped and resolved when the context is created. In Program.cs, build the DbContext options with AddInterceptors:

builder.Services.AddScoped<IHttpContextAccessor>();
builder.Services.AddScoped<AuditInterceptor>();

builder.Services.AddScoped<AppDbContext>(sp =>
{
    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseSqlServer(builder.Configuration
            .GetConnectionString("Default"))
        .AddInterceptors(sp.GetRequiredService<AuditInterceptor>())
        .Options;

    return new AppDbContext(options);
});

Registering AuditInterceptor as scoped means every HTTP request gets its own instance with the correct user identity. Background services and workers resolve the same interceptor but read a null HTTP context, so the fallback "system" identity keeps those changes audited too. For pure infrastructure writes like the migrations history table, filter by entity name inside the interceptor to avoid noise.

FAQ

Do interceptors see the raw SQL or just entity values?

SaveChangesInterceptor sees the ChangeTracker entities and their property values. If you need the final SQL, IDbCommandInterceptor exposes the command text before execution — but for audit trails, entity-level before-and-after values are almost always what you want.

Can one interceptor handle multiple DbContexts?

Yes. The interceptor receives DbContextEventData, which carries the context instance. Check the context type and its entity metadata before recording, so a single registration can audit every context in the app.

What if a background job has no HTTP context?

Inject IHttpContextAccessor and fall back to a constant such as "system" when HttpContext is null. The audit row is still written; only the identity is generic.

Should I audit reads as well?

Usually not. Audit trails focus on state changes. For sensitive data you can add access logs separately, but recording every read turns the table into a firehose with little compliance value.

Key Takeaways

  • One SaveChangesInterceptor audits every save in the application
  • Read the ChangeTracker for before and after values, and diff only modified properties
  • Add audit rows to the same DbContext so they commit atomically
  • Register the interceptor as scoped and resolve the user from IHttpContextAccessor
  • Indotalent products ship a working audit interceptor in complete .NET 10 source — $21 each

Ready to let EF Core interceptors do the audit work for you?

Every Indotalent product includes a complete audit trail implementation in its .NET 10 source code. Complete source code — $21 each.

Explore Products