EF Core Audit Interceptor
Automatic CreatedOn/UpdatedOn Timestamps Without Touching Controllers
If you’ve ever set CreatedOn and UpdatedOn in every controller action, you already know it becomes repetitive fast.
This project shows a cleaner pattern: centralize auditing in EF Core with a SaveChangesInterceptor so inserts and updates are stamped automatically.
In this post, I’ll walk through a minimal ASP.NET Core Web API + SQLite example that does exactly that
Why this pattern matters
Most teams start with “just set timestamps in the API layer.” That works until:
You forget to set a value in one endpoint
You add background jobs that bypass controllers
You duplicate the same logic across services and handlers
You accidentally allow clients to overwrite system fields
A SaveChangesInterceptor solves this at the persistence boundary.
If an entity is tracked and saved, audit values are applied consistently.
What this demo app contains
Your EFCoreAuditInterceptor sample is intentionally focused:
ASP.NET Core Web API
EF Core + SQLite
One entity: Customer
One base type: AuditableEntity
One interceptor: AuditInterceptor
CRUD controller with no manual audit assignments
That keeps the concept sharp and easy to reuse.
Step 1: Define a shared auditable base class
All entities that need auditing inherit from a base type.
namespace EFCoreAuditInterceptor.Data
{
public abstract class AuditableEntity
{
public DateTime CreatedOn { get; set; }
public DateTime? UpdatedOn { get; set; }
}
}Why nullable UpdatedOn?
A new row may not have been updated yet, so null communicates that state clearly.
Step 2: Make your entity inherit it
namespace EFCoreAuditInterceptor.Data
{
public class Customer : AuditableEntity
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
}
}Now Customer automatically participates in auditing logic.
Step 3: Implement the SaveChangesInterceptor
This is the core of the pattern.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace EFCoreAuditInterceptor.Data
{
public class AuditInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
ApplyAuditInfo(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
ApplyAuditInfo(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void ApplyAuditInfo(DbContext? context)
{
if (context == null)
return;
foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedOn = DateTime.UtcNow;
}
if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedOn = DateTime.UtcNow;
// Prevent accidental overwrite of original CreatedOn value
entry.Property(x => x.CreatedOn).IsModified = false;
}
}
}
}
}Important behavior:
Added entities get CreatedOn
Modified entities get UpdatedOn
CreatedOn is protected during updates
Step 4: Register interceptor once in DI/DbContext pipeline
Your app already wires this via AddDbContext options (great).
Recommendation: keep that single registration path and avoid adding it again in OnConfiguring.
Program setup pattern:
builder.Services.AddScoped<AuditInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseSqlite("Data Source=EFCoreAuditInterceptor.db");
options.AddInterceptors(sp.GetRequiredService<AuditInterceptor>());
});And DbContext stays lean:
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Customer> Customers => Set<Customer>();
}Step 5: Keep controllers clean (no audit code)
Your controller already does this correctly: create/update customer values, call SaveChangesAsync, done.
Create action style:
var customer = new Customer
{
Name = request.Name,
IsDeleted = request.IsDeleted
};
_context.Customers.Add(customer);
await _context.SaveChangesAsync();No CreatedOn/UpdatedOn assignment is needed anywhere in API logic.
End-to-end behavior
Create customer
Request:
POST /api/customers
{
"name": "Alice",
"isDeleted": false
}Response includes auto-set CreatedOn:
{
"id": 1,
"name": "Alice",
"isDeleted": false,
"createdOn": "2026-03-02T10:15:21.123Z",
"updatedOn": null
}Update customer
Request:
PUT /api/customers/1
{
"name": "Alice Cooper",
"isDeleted": false
}Response shows UpdatedOn stamped automatically while CreatedOn stays unchanged.
One demo note in your current project
You currently recreate the database on app startup (EnsureDeleted + EnsureCreated).
That is perfect for demos, but in real apps switch to EF Core migrations so data persists across runs.
Why this scales well
This approach stays maintainable because:
Audit rules are centralized in one place
Every save path is covered (controllers, services, jobs)
API contracts remain focused on business data
You reduce bugs from inconsistent timestamp handling
As your model grows, each new auditable entity simply inherits AuditableEntity.
Production hardening ideas (next steps)
If you evolve this demo into a production baseline, consider adding:
CreatedBy and UpdatedBy (from authenticated user context)
Soft-delete timestamps (DeletedOn, DeletedBy)
Multi-tenant audit context
UTC normalization tests around interceptor behavior
Database defaults as a fallback safety net
Summary
This project demonstrates a clean auditing pattern where EF Core automatically stamps CreatedOn and UpdatedOn at save time.
The key idea is centralizing audit logic in a SaveChangesInterceptor, so application code stays simple and consistent.
Entities inherit from a shared auditable base type, and the interceptor applies rules based on entity state (Added / Modified).
This reduces duplication, prevents missed timestamp updates, and scales well as the domain grows.
For production, keep single interceptor registration, use migrations (not recreate DB), and consider adding user-based audit fields.
Question for you: Which one do you find yourself using most often in your C# projects, and why?
👉 Full working code available at:
🔗https://sourcecode.kanaiyakatarmal.com/EFCoreAuditInterceptor
I hope you found this guide helpful and informative.
Thanks for reading!
If you enjoyed this article, feel free to share it and follow me for more practical, developer-friendly content like this.


