---
title: EF Core audit fields without an interceptor
date: 2025-02-04
tags: dotnet, ef-core, aspnet-core, architecture
summary: Set CreatedOn, CreatedBy, UpdatedOn and UpdatedBy in a SaveChanges override, and when it's time to move to a SaveChangesInterceptor.
---

Every entity in my project has four audit columns: who created it, when, who changed it last, and when. I don't want any service to set them by hand. Somebody forgets it, and then the data lies.

The usual answer is an EF Core `SaveChangesInterceptor`. I started with something simpler: an override of `SaveChanges` in the `DbContext`. It does the same job.

## The base entity

All entities share an interface, so the context can find them in the change tracker:

```csharp title="IBaseEntity.cs"
public interface IBaseEntity
{
    Guid CreatedBy { get; set; }
    DateTime CreatedOn { get; set; }
    Guid UpdatedBy { get; set; }
    DateTime UpdatedOn { get; set; }
}
```

The user IDs are Guids because the context is an `IdentityDbContext` with Guid keys. The value in `CreatedBy` is the local ASP.NET Identity user ID.

Users log in with Entra ID, but Entra is only an external login. An IdentityServer with ASP.NET Identity sits in between: on the first login it creates a local user with a new Guid ID and stores Entra's ID only as the login key. The tokens the APIs trust are issued by IdentityServer, and their `sub` claim is that local user ID. Entra's own `sub` (not a Guid) never reaches the APIs.

## The override

```csharp title="ApplicationDbContext.cs"
public override int SaveChanges()
{
    ChangeTracker.DetectChanges();
    UpdateTrackingEntityProperties();
    return base.SaveChanges();
}

public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
    ChangeTracker.DetectChanges();
    UpdateTrackingEntityProperties();
    return base.SaveChangesAsync(ct);
}

private void UpdateTrackingEntityProperties()
{
    var user = UserContextHelper.GetCurrentUserId();
    var now = DateTime.UtcNow;

    foreach (var e in ChangeTracker.Entries<IBaseEntity>())
    {
        if (e.State == EntityState.Added)
        {
            e.Entity.CreatedOn = now; e.Entity.CreatedBy = user;
            e.Entity.UpdatedOn = now; e.Entity.UpdatedBy = user;
        }
        else if (e.State == EntityState.Modified)
        {
            e.Entity.UpdatedOn = now; e.Entity.UpdatedBy = user;
        }
    }
}
```

`DetectChanges()` runs first, so entities that were changed without the tracker noticing still get the `Modified` state before we look at it.

Sometimes a save should *not* touch the audit fields: system jobs, or a consent record where the original timestamp is the whole point. For that there's a second method that skips the update:

```csharp title="ApplicationDbContext.cs"
public Task<int> SystemSaveChangesAsync(CancellationToken ct = default)
{
    ChangeTracker.DetectChanges();
    return base.SaveChangesAsync(ct);
}
```

## Who is the current user?

The context is created by `IDbContextFactory<T>` and is also used in background workers. So I resolve the user without DI:

```csharp title="UserContextHelper.cs"
internal static class UserContextHelper
{
    // AsyncLocal-backed, so a static instance works
    private static readonly HttpContextAccessor _accessor = new();

    internal static Guid GetCurrentUserId()
    {
        try
        {
            var principal = _accessor.HttpContext?.User;
            var sub = principal?.FindFirst("sub")?.Value;
            return sub is null ? Guid.Empty : Guid.Parse(sub);
        }
        catch { return Guid.Empty; }
    }
}
```

`HttpContextAccessor` keeps the current context in a static `AsyncLocal`, so a `new` instance sees the same request as the one from DI. But ASP.NET Core only sets that context when an accessor is registered, so `builder.Services.AddHttpContextAccessor()` has to be called in every web host. Without it, `HttpContext` is always null.

`Guid.Empty` means "system". You see it on rows written by workers, seeders and anonymous requests.

> Watch out: this reads the `sub` claim. If your authentication handler has `MapInboundClaims` on (the default), `sub` is renamed to the long `nameidentifier` claim, and every row is quietly saved as `Guid.Empty`. That's why every host in this project sets it to `false`. I wrote about that in [Keycloak in Aspire](/p/2026-06-04-aspire-keycloak/).

## The trade-off

This is simple and it works in every host. But it's static and implicit:

- It's hard to test. You can't inject a fake user or a fake clock.
- It doesn't work in Blazor Server circuits. `HttpContext` is only reliable for the first request, not for the interactive circuit after it.

If either one starts to hurt, move to an interceptor.

## The interceptor version

The same logic as a `SaveChangesInterceptor`, with the user and the clock injected:

```csharp title="AuditSaveChangesInterceptor.cs"
public sealed class AuditSaveChangesInterceptor(ICurrentUser currentUser, TimeProvider clock)
    : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct = default)
    {
        if (eventData.Context is { } db) Apply(db);
        return base.SavingChangesAsync(eventData, result, ct);
    }

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData, InterceptionResult<int> result)
    {
        if (eventData.Context is { } db) Apply(db);
        return base.SavingChanges(eventData, result);
    }

    private void Apply(DbContext db)
    {
        var now = clock.GetUtcNow().UtcDateTime;
        var user = currentUser.Id;
        foreach (var e in db.ChangeTracker.Entries<IBaseEntity>())
        {
            if (e.State == EntityState.Added) { e.Entity.CreatedOn = now; e.Entity.CreatedBy = user; }
            if (e.State is EntityState.Added or EntityState.Modified) { e.Entity.UpdatedOn = now; e.Entity.UpdatedBy = user; }
        }
    }
}
```

Two things to know before you switch:

- **Pooling.** Aspire's `AddSqlServerDbContext` registers a pooled context. The options, and with them the interceptors, are built once for the pool. So the interceptor has to be a singleton, and `ICurrentUser` must look up the user at save time (for example through `IHttpContextAccessor`), not capture it in its constructor.
- **No hook for options with DI.** `AddSqlServerDbContext` gives you no `IServiceProvider` to resolve the interceptor. Register the context yourself and let Aspire enrich it:

```csharp title="registration"
builder.Services.AddSingleton<AuditSaveChangesInterceptor>();
builder.Services.AddDbContextPool<ApplicationDbContext>((sp, options) =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("database"))
           .AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>()));
builder.EnrichSqlServerDbContext<ApplicationDbContext>(); // retries, health check, tracing
```

And `SystemSaveChangesAsync` needs a replacement: a flag on the context that the interceptor checks before it sets anything.

Until then, the override does the job in a few lines. The audit fields also do double duty: in [the next post](/p/2025-02-25-ef-core-concurrency-temporal-tables/) `UpdatedOn` becomes a concurrency token.
