Auditing in EF Core – Automatic CreatedAt, UpdatedAt, and UserId with SaveChanges Overrides

Article Sponsors

EF Core too slow? Insert data up to 14x faster and cut save time by 94%. Boost performance with extension methods fully integrated into EF Core — Bulk Insert, Update, Delete, and Merge.

Join 5,000+ developers who’ve trusted our library since 2014.

👉 Try it now — and feel the difference

If you’ve built more than a couple of production APIs, you’ve written this code before: set CreatedAt on insert, set UpdatedAt on every update, stamp the current user somewhere, and hope nobody forgets to do it in the one service method that slipped through review. Audit fields are simple in theory and surprisingly easy to get wrong in practice — because the discipline is manual, and manual discipline doesn’t scale across a team or a codebase that grows for years.

EF Core gives you a much cleaner answer: override SaveChanges and SaveChangesAsync once, inspect the ChangeTracker, and populate audit fields automatically for every entity that opts in. No repeated boilerplate, no forgotten fields, no inconsistent behavior between controllers.

This article walks through building that from scratch, with a current-user abstraction that actually works in a DI-based ASP.NET Core app, and a look at when this approach makes more sense than reaching for interceptors.

Note: If you’re not familiar with EF Core Interceptors yet, it’s worth reading that first — this article assumes you know they exist, since we’ll compare the two approaches directly later on.

What We’re Building

By the end of this article, you’ll have:

  • An IAuditableEntity contract that any entity can implement
  • A SaveChanges/SaveChangesAsync override that stamps CreatedAt, UpdatedAt, CreatedBy, and UpdatedBy automatically
  • An ICurrentUserService abstraction so your DbContext never touches HttpContext directly
  • A clear answer to “should this be a SaveChanges override or an interceptor?

Defining the Audit Contract

Start with an interface. Entities that need auditing implement it — entities that don’t (lookup tables, static reference data) simply don’t.

C#
namespace Domain.Common;

public interface IAuditableEntity
{
    DateTimeOffset CreatedAt { get; set; }
    string? CreatedBy { get; set; }
    DateTimeOffset? UpdatedAt { get; set; }
    string? UpdatedBy { get; set; }
}

A couple of deliberate choices here worth calling out:

  • DateTimeOffset over DateTime. A plain DateTime doesn’t tell you which timezone it was saved in — so six months later, nobody can say for sure if 2026-01-15 09:00 means 9 AM in Colombo, UTC, or wherever the server happened to be. DateTimeOffset represents a point in time together with its UTC offset, so it doesn’t carry this ambiguity. Since we use DateTimeOffset.UtcNow in the override below, every stored value carries a +00:00 offset — giving you an unambiguous UTC timestamp regardless of where the app or database happens to run.
  • Nullable UpdatedAt/UpdatedBy. A brand-new record hasn’t been updated by anyone yet — so instead of forcing a fake value in there, we just leave it as null. That way, null genuinely means “never updated,” and you’re not stuck guessing whether a real value was set on purpose.

Applying it to an entity is a one-liner:

C#
namespace Domain.Entities;

public sealed class Order : IAuditableEntity
{
    public int Id { get; set; }
    public required string CustomerName { get; set; }
    public decimal Total { get; set; }

    public DateTimeOffset CreatedAt { get; set; }
    public string? CreatedBy { get; set; }
    public DateTimeOffset? UpdatedAt { get; set; }
    public string? UpdatedBy { get; set; }
}

Getting the Current User Without Coupling Your DbContext to HTTP

Before touching SaveChanges, you need a reliable way to answer “who is making this change?” — and it needs to work whether the call originates from an API request, a background job, or a test.

The naive approach is injecting IHttpContextAccessor straight into your DbContext. It works, but it quietly couples your data layer to ASP.NET Core, and it breaks the moment you call SaveChanges from a console app, a Hangfire job, or an integration test with no HttpContext in play.

Best practice: wrap the current-user lookup behind an abstraction your DbContext depends on, and let the web layer be the only place that knows about HttpContext.

C#
namespace Application.Abstractions;

public interface ICurrentUserService
{
    string? UserId { get; }
}

The ASP.NET Core implementation reads from the authenticated user’s claims:

C#
namespace Infrastructure.Services;

public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUserService
{
    public string? UserId =>
        httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}

For background jobs or seed scripts, you register a different implementation — one that returns a fixed value like "system" or reads from job metadata. The DbContext never needs to know which one it’s talking to.

What Happens When SaveChanges Runs from a Background Job

This is where the abstraction earns its keep. If CurrentUserService above is the only registered implementation and SaveChanges runs from a Hangfire job, a hosted service, or a queue consumer, it won’t throw — HttpContext is simply null outside a web request, so the ?. chain short-circuits and UserId resolves to null. Your audit fields still get populated, just with no meaningful CreatedBy/UpdatedBy value, which defeats the purpose of auditing in the first place.

The fix is to register a different ICurrentUserService implementation for the job’s DI scope — one that doesn’t depend on HTTP at all:

C#
namespace Infrastructure.Services;

public sealed class BackgroundJobUserService : ICurrentUserService
{
    public string? UserId => "system";
}

If jobs act on behalf of a specific user — for example, a queued task triggered by a user’s request — resolve the identity per job instead of hardcoding a fixed value:

C#
namespace Infrastructure.Services;

public sealed class JobUserService(string userId) : ICurrentUserService
{
    public string? UserId { get; } = userId;
}

Register whichever implementation fits the execution context — Hangfire jobs typically get their own DI scope per run, so swapping the registration there is enough. The DbContext and its SaveChanges override never change; they just resolve a different ICurrentUserService depending on where the call originated.

Best practice: never let ICurrentUserService throw when there’s no user context. Returning null (or "system") keeps SaveChanges working everywhere it’s called — an exception here would take down background processing for a problem that isn’t actually an error.

Overriding SaveChanges and SaveChangesAsync

With the contract and the current-user service in place, the override itself is straightforward. The key is using ChangeTracker.Entries<IAuditableEntity>() to find only the entities that opted in, then branching on EntityState.

C#
namespace Infrastructure.Persistence;

public sealed class AppDbContext(
    DbContextOptions<AppDbContext> options,
    ICurrentUserService currentUserService) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    public override int SaveChanges(bool acceptAllChangesOnSuccess)
    {
        ApplyAuditInfo();
        return base.SaveChanges(acceptAllChangesOnSuccess);
    }

    public override Task<int> SaveChangesAsync(
        bool acceptAllChangesOnSuccess,
        CancellationToken cancellationToken = default)
    {
        ApplyAuditInfo();
        return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }

    private void ApplyAuditInfo()
    {
        var utcNow = DateTimeOffset.UtcNow;
        var userId = currentUserService.UserId;

        foreach (var entry in ChangeTracker.Entries<IAuditableEntity>())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.Entity.CreatedAt = utcNow;
                    entry.Entity.CreatedBy = userId;
                    break;

                case EntityState.Modified:
                    entry.Entity.UpdatedAt = utcNow;
                    entry.Entity.UpdatedBy = userId;
                    break;
            }
        }
    }
}

A few details that matter more than they look:

  • Override both sync and async overloads. If your app calls SaveChanges() anywhere — a legacy path, a script, a test helper — skipping the sync override means audit fields silently don’t populate there.
  • Capture utcNow once per call, not per entity. If you’re saving 200 entities in one batch, they should all carry the exact same timestamp rather than 200 microscopically different ones.
  • Only touch Modified entries for updates. EF Core already tracks state correctly through ChangeTracker, so you don’t need to manually diff anything — trust the tracker.
EF Core performance optimization sponsor banner showing bulk insert, update, delete, and merge features with 14x faster data operations.

Handling Owned Types and Nested Entities

If you’re using Complex Types or owned entities for value objects like Address or Money, note that ChangeTracker.Entries<IAuditableEntity>() only picks up entities that implement the interface directly — owned types typically shouldn’t carry their own audit fields anyway, since they’re conceptually part of their owner’s row. If a child entity genuinely needs independent auditing (e.g., OrderLine in a one-to-many relationship), just have it implement IAuditableEntity too; the loop above already covers every tracked entity type, not just aggregate roots.

SaveChanges Override vs. Interceptors

Both approaches solve the same problem, and EF Core supports either one — the right choice depends on scope and how much else you’re layering onto SaveChanges.

SaveChanges OverrideSaveChangesInterceptor
Setup complexitycomplexity Minimal — override two methods on your DbContextSlightly more — implement/inherit an interceptor, register it
ScopeTied to one DbContextReusable across multiple DbContext types
Best forSingle-context apps, straightforward audit logicShared logic across contexts, or combining with other cross-cutting concerns (soft delete, outbox pattern, SQL logging)
TestabilitySimple — test the DbContext directlyRequires registering the interceptor in test setup
ComposabilityEverything lives in one method — can get crowded if you add more concernsEach concern can live in its own interceptor, composed via registration

If you only have one DbContext and auditing is the only cross-cutting concern you’re handling, a SaveChanges override is the simpler, more direct tool — reach for interceptors once you need the same logic shared across contexts or you’re stacking multiple concerns (auditing, soft delete, domain event dispatch) that would otherwise bloat a single override

Testing the Behavior

Because the logic lives entirely inside SaveChanges, testing it doesn’t require mocking EF Core internals — just an in-memory or SQLite provider and a fake ICurrentUserService.

C#
[Fact]
public async Task SaveChangesAsync_Sets_CreatedAt_And_CreatedBy_On_Insert()
{
    var currentUser = Substitute.For<ICurrentUserService>();
    currentUser.UserId.Returns("test-user");

    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseInMemoryDatabase(Guid.NewGuid().ToString())
        .Options;

    await using var context = new AppDbContext(options, currentUser);

    var order = new Order { CustomerName = "Acme Corp", Total = 199.99m };
    context.Orders.Add(order);

    await context.SaveChangesAsync();

    Assert.NotEqual(default, order.CreatedAt);
    Assert.Equal("test-user", order.CreatedBy);
    Assert.Null(order.UpdatedAt);
}

Common Pitfalls

  • Forgetting the sync override. Overriding only SaveChangesAsync leaves any synchronous SaveChanges() call unaudited — a subtle bug that only shows up in the one code path nobody tested.
  • Using DateTime.Now instead of DateTime.UtcNow / DateTimeOffset.UtcNow. Local server time in a database column becomes a liability the moment you deploy across regions or daylight-saving boundaries.
  • Re-stamping CreatedAt on update. Double-check your switch logic only touches CreatedAt/CreatedBy on EntityState.Added — a copy-paste slip here silently overwrites your original creation timestamp on every edit.
  • Calling SaveChanges inside a loop per entity. Batch your changes and call SaveChanges once; besides the performance cost, it also means your audit timestamps end up inconsistent across what should be a single logical operation.
  • Assuming ExecuteUpdate/ExecuteDelete are covered. They aren’t. These APIs execute directly against the database, bypassing the ChangeTracker and SaveChanges entirely — so this override never runs, and rows updated or deleted this way won’t get audit stamps. Microsoft’s own documentation calls this out explicitly. If you rely on bulk ExecuteUpdate/ExecuteDelete calls anywhere, you’ll need to set audit fields manually in the update expression itself, or avoid these APIs on auditable entities.

Summary

Automatic auditing is one of those small infrastructure investments that pays for itself the first time someone asks “who changed this record, and when?” By centralizing CreatedAt, UpdatedAt, and user tracking inside a single SaveChanges override, you remove an entire category of bugs caused by forgetting to set a field manually — and you get a consistent, testable audit trail across your entire application with almost no ongoing maintenance cost.

This article is sponsored by ZZZ Projects.

Thousands of developers fixed EF Core performance — with one library: Entity Framework Extensions.

👉 Insert data 14x faster with Bulk Insert

Found this article useful? Share it with your network and spark a conversation.