AuditAugust 2026 · 6 min read

Audit Trail Implementation: Logging Every Change in Your .NET App

TL;DR

A complete audit trail captures who changed what, when, and the before and after values. In .NET, you hook one SaveChangesAsync call, write structured events to a single AuditLog table, and keep records intact with soft deletes.

Audit Trail Implementation is one of those features every serious .NET application eventually needs: a permanent, searchable record of who changed what, when, and what the previous value was. Without it, an unexpected record edit becomes a mystery you can never fully solve. Support tickets pile up, customers blame your team, and nobody can prove what actually happened. With a solid audit trail you can answer "who touched this order, and what did they change?" in seconds — and show your work.

The good news is that a complete audit trail does not require a separate service, an event bus, or a data warehouse. In a .NET 10 application built with EF Core, you can capture every change in one overridden SaveChangesAsync call, store structured events in a single table, and query them later from the admin UI. This article walks through the pieces: what to capture, the data model, automatic before-and-after value capture, and how soft deletes keep the trail honest.

Audit Trail Implementation: What to Capture in Every Change

Before you write a single line of C#, decide what an audit event looks like. Regulators, auditors, and support teams care about a surprisingly small set of fields. If you capture these five things, you can reconstruct almost any historical situation:

  • Who — the user id from ASP.NET Core Identity, plus a display name for readable reports
  • What — the entity type, the record id, and the action: Create, Update, or Delete
  • When — an exact timestamp in UTC so time zones never distort the story
  • Before and after — the property values that actually changed, serialized as JSON
  • Why — an optional reason string captured from the business flow

Resist the temptation to log everything. A full snapshot of every row on every save bloats the table and makes reports useless. Log the entity, the record id, the action, and only the properties whose IsModified flag is true. JSON snapshots keep the payload flexible when your entities evolve over time.

The Audit Trail Implementation Data Model

The audit table itself is intentionally simple. You do not need normalized tables, a column per property, or a polymorphic design. A single AuditLog entity with JSON payloads gives you flexibility and keeps queries fast:

public class AuditLog
{
    public long Id { 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? OldValuesJson { get; set; }
    public string? NewValuesJson { get; set; }
    public string UserId { get; set; } = string.Empty;
    public string UserName { get; set; } = string.Empty;
    public DateTimeOffset ChangedAt { get; set; }
    public string? Reason { get; set; }
}

Register the entity as a DbSet, configure an index on EntityName, EntityId, and ChangedAt, and make the timestamp a DateTimeOffset so the stored moment is never ambiguous. In an Indotalent-style vertical slice, this entity lives in its own feature folder and is referenced from the shared application DbContext.

Capture Before and After Values Automatically

The mistake most teams make is sprinkling audit logging calls through every endpoint and handler. That approach misses changes, duplicates logic, and couples business code to auditing. Instead, intercept the unit of work itself. Every handler in the application calls SaveChangesAsync at the end of its transaction, so hooking that one method covers every feature at once:

public override async Task<int> SaveChangesAsync(
    CancellationToken cancellationToken = default)
{
    var entries = ChangeTracker.Entries<EntityBase>()
        .Where(e => e.State is EntityState.Added
            or EntityState.Modified
            or EntityState.Deleted);

    foreach (var entry in entries)
    {
        var log = new AuditLog
        {
            EntityName = entry.Entity.GetType().Name,
            EntityId = entry.Property("Id").CurrentValue?.ToString() ?? "",
            Action = entry.State.ToString(),
            UserId = _currentUser.Id,
            ChangedAt = DateTimeOffset.UtcNow
        };

        if (entry.State == EntityState.Modified)
        {
            log.OldValuesJson = ToJson(entry.OriginalValues);
            log.NewValuesJson = ToJson(entry.CurrentValues);
        }

        AuditLogs.Add(log);
    }

    return await base.SaveChangesAsync(cancellationToken);
}

Because EF Core tracks original and current values for you, the interceptor can diff them without any reflection magic. Serialize the changed properties with System.Text.Json, store both snapshots, and you can later render a "changed from X to Y" message for any record. The same pattern works as an EF Core SaveChangesInterceptor or inside a MediatR pipeline behavior if you prefer to keep the DbContext untouched.

Soft Deletes Keep the Trail Honest

A hard DELETE removes the row — and then the audit trail points at a record that no longer exists. For entities that matter to auditing, use a soft delete: a boolean IsDeleted flag plus a global query filter so normal queries never see deleted rows:

modelBuilder.Entity<Product>()
    .HasQueryFilter(p => !p.IsDeleted);

When the interceptor sees a soft delete, the ChangeTracker state is Modified, not Deleted, so the before-and-after logic already handles it. The deleted row stays in the database, the audit trail stays complete, and you can build an admin page to restore records if you ever need to. This matters for every enterprise product, from CRM deals to HRM employee records.

FAQ

Does audit logging slow down the application?

Writing one small row per save adds microseconds to a typical operation. Keep the index narrow, avoid storing large blobs in the event, and archive older rows to a partitioned or cold table if the volume grows.

Should audit events live in the same database as business data?

For most products, yes. Writing the audit row in the same transaction guarantees the record is atomic with the change — no queue, no failed background job, no lost events. Move to a dedicated store only when compliance demands stronger isolation.

How does the audit trail interact with GDPR deletion requests?

Treat audit rows as records of processing rather than personal data to delete. Anonymize the user reference instead of deleting the event, so you keep the trail and satisfy the request. The compliance article covers this design in detail.

Key Takeaways

  • Capture who, what, when, before and after values, and reason in every audit event
  • Hook SaveChangesAsync once instead of sprinkling logging through every handler
  • Store changed properties as JSON in a single AuditLog table with a narrow index
  • Use soft deletes so the audit trail never points at a missing row
  • Indotalent's complete .NET 10 source code includes audit trail features — $21 each

Ready to study audit trails in a complete production app?

Every Indotalent product ships with audit trail features built into its complete .NET 10 source code. Complete source code — $21 each.

Explore Products