AuditAugust 2026 · 6 min read

Audit Trail Implementation: Who Changed What, When, and Why

TL;DR

Every audit event should answer four questions: who, what, when, and why. Resolve identity from claims in one scoped service, stamp server-generated UTC timestamps, and let each feature attach an optional reason that the interceptor writes automatically.

Audit Trail Implementation is, at its core, about answering four questions after the fact: who changed the record, what did they change, when did it happen, and why. Most applications nail the "what" and "when" almost by accident, because timestamps and change capture come free with EF Core. The "who" and the "why" are harder — they require you to thread identity and business intent through the entire request pipeline. This article shows how to capture all four answers in one place.

The design has three moving parts: a scoped user context that knows the current identity, a ChangeTracker-based capture that records before and after values, and an explicit reason mechanism that lets each feature say why it made the change. None of them require changes to existing handlers.

Audit Trail Implementation Answers Four Questions

Every audit event should be a self-contained answer sheet. When support or an auditor opens one row, it should tell the whole story without joins and without asking the person who did the work:

  • Who — resolved from the authenticated user's claims, not from a property you set manually
  • What — the entity, the record id, and the changed properties with old and new values
  • When — a single server-generated UTC timestamp
  • Why — an optional but encouraged reason from the business flow

The four answers are independent. A batch job has a who ("system") and a when, but usually no specific why. A manager reassigning a territory has a who and a why. Design the audit event so any subset can be populated without breaking the others.

Audit Trail Implementation: Where the Four Answers Live

One AuditLog row per event holds all four answers. The schema is deliberately flat — wide enough to answer questions directly, small enough to stay fast:

public class AuditLog
{
    public long Id { get; set; }
    public int SequenceNumber { get; set; }
    public string EntityName { get; set; } = string.Empty;
    public string EntityId { get; set; } = string.Empty;
    public string Action { get; set; } = string.Empty;
    public string UserId { get; set; } = string.Empty;
    public string UserName { get; set; } = string.Empty;
    public DateTimeOffset ChangedAt { get; set; }
    public string? OldValuesJson { get; set; }
    public string? NewValuesJson { get; set; }
    public string? Reason { get; set; }
}

Note the UserName column. When you store only a user id, the trail becomes unreadable the day the user's profile is edited or an account is deactivated. Denormalizing the display name at event time keeps reports readable forever, even if the account later disappears.

Capturing the "Who": Resolve Identity in One Place

The classic mistake is passing the user id into every handler as a parameter. That couples business code to identity plumbing and is easy to forget. Instead, resolve it once from validated JWT or Identity claims in a scoped service:

public sealed class CurrentUser
{
    private readonly IHttpContextAccessor _http;

    public CurrentUser(IHttpContextAccessor http) => _http = http;

    public string Id =>
        _http.HttpContext?.User
            .FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "anonymous";

    public string Name =>
        _http.HttpContext?.User
            .FindFirst("display_name")?.Value ?? "Anonymous";
}

Register CurrentUser as scoped and resolve it inside the save interceptor. Because the interceptor runs before the transaction commits, the identity is guaranteed to be written atomically with the change — there is no way to end up with a change that has no owner.

Capturing the "Why": Reasons from the Business Flow

"Why" is the most fragile answer to capture. Users rarely type a reason when the UI does not ask, and forcing it slows them down. The pragmatic approach is a scoped ChangeReason that a feature sets only when it has something useful to say. The interceptor reads it and stamps every event created during that save:

public sealed class ChangeReason
{
    public string? Value { get; private set; }
    public void Set(string reason) => Value = reason;
}

// inside a feature handler
_changeReason.Set("Territory reassigned by regional manager");

await _mediator.Send(command);
await _db.SaveChangesAsync(ct);

The interceptor simply copies ChangeReason.Value into the Reason column of every event it creates. If the value is null, the event still records who, what, and when — the reason stays optional, but every feature can provide it without coupling to the audit mechanism.

Read the Trail Back as a Story

Finally, present the audit trail as sentences humans can read. A query projects UserName, ChangedAt, Action, EntityName, and the JSON diff, and a renderer turns the row into "Jane Wilson updated Customer #1042 at 09:41 UTC: phone changed from X to Y." Combining the JSON snapshots with the denormalized name turns raw rows into a timeline your support team can actually use — and a record you can stand behind.

FAQ

Should I store the user id or the username in the audit trail?

Both. Store the id for joins and accountability, and denormalize the display name so the trail stays readable after accounts are renamed or deactivated.

What about changes made by background jobs and webhooks?

Resolve the identity from whatever context exists — an API key for webhooks, a fixed "system" claim for jobs. The important rule is that identity is resolved once and stamped automatically.

Can users see their own audit history?

You can offer a personal activity view, but raw audit data with other users' names belongs behind an admin or Auditor role. Project it through a service that filters by the requestor's permissions.

What if a user changes their name later?

Because you denormalized the name at event time, the trail keeps the name that was current when the change happened. Historical records stay stable.

Key Takeaways

  • Every audit event answers who, what, when, and why
  • Resolve identity from claims in one scoped service, not per-handler parameters
  • Denormalize the display name so the trail stays readable forever
  • Let features set an optional reason that the interceptor stamps automatically
  • Indotalent .NET 10 source code includes full audit trail features — $21 each

Ready to prove who changed what, when, and why?

Every Indotalent product captures user identity and change reasons in its complete .NET 10 source code. Complete source code — $21 each.

Explore Products