BaseEntitySaveChangesInterceptor
Namespace: RA.Utilities.Data.EntityFramework.Interceptors
BaseEntitySaveChangesInterceptor sets audit timestamps immediately before EF Core writes changes to the database. It is a SaveChangesInterceptor that overrides both SavingChanges and SavingChangesAsync.
🎯 Purpose
Audit fields are easy to forget and easy to set inconsistently. The interceptor centralizes the logic: whenever an entity is added, CreatedAt is set to DateTime.UtcNow; whenever an entity is modified, LastModifiedAt is set to DateTime.UtcNow. No repository or business code has to remember.
How it works
- EF Core calls
SavingChanges(sync) orSavingChangesAsync(async). - The interceptor enumerates
context.ChangeTracker.Entries(). - For every entry in the
Addedstate, it checks the entity's model metadata for aCreatedAtproperty and sets it. - For every entry in the
Modifiedstate, it checks forLastModifiedAtand, when present, sets it.
The check is metadata-based (entry.Metadata.FindProperty(...)), not type-based. Any entity that exposes a property with those names is updated, whether or not it derives from a RA.Utilities entity base class.
foreach (EntityEntry entry in context.ChangeTracker.Entries())
{
if (entry.State is EntityState.Added && entry.Metadata.FindProperty(CreatedAtProperty) is not null)
{
entry.Property(CreatedAtProperty).CurrentValue = DateTime.UtcNow;
}
if (entry.State is EntityState.Modified && entry.Metadata.FindProperty(LastModifiedAtProperty) is not null)
{
entry.Property(LastModifiedAtProperty).CurrentValue = DateTime.UtcNow;
}
}
Which entities are affected
| Entity base | CreatedAt | LastModifiedAt |
|---|---|---|
CoreEntity<TKey> | ❌ | ❌ |
BaseEntity<TKey> | ✅ | ❌ |
WriteEntity<TKey> | ✅ | ✅ |
AuditableBaseEntity<TKey> | ✅ | ✅ (plus CreatedBy / LastModifiedBy, not set by this interceptor) |
SoftDeleteEntity<TKey> | ✅ | ✅ (IsDeleted is not managed by this interceptor) |
The interceptor writes DateTime.UtcNow for both properties. It does not use the database clock.
🚀 How to Use
Register the interceptor and attach it to the context's options:
using RA.Utilities.Data.EntityFramework.Interceptors;
builder.Services.AddScoped<BaseEntitySaveChangesInterceptor>();
builder.Services.AddDbContext<DbContext, AppDbContext>((provider, options) =>
options.UseSqlServer(connectionString)
.AddInterceptors(provider.GetRequiredService<BaseEntitySaveChangesInterceptor>()));
For a context constructed without DI, attach it directly:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(connectionString)
.AddInterceptors(new BaseEntitySaveChangesInterceptor());
}
⚠️ Behavior and limitations
-
Only
AddedandModified—Deletedentities keep their timestamps, andUnchangedentities are untouched. -
Exact property names — the properties must be named
CreatedAtandLastModifiedAtto be detected. -
Timestamps only —
CreatedByandLastModifiedByare not populated; read the current user and set them in your command handler or a separate interceptor. -
Soft delete is not applied —
SoftDeleteEntity<TKey>.IsDeletedis not queried or filtered automatically. Add a global query filter and set the flag in your own code. -
DateTime, notDateTimeOffset— the properties areDateTime; values are UTC. -
Pooled contexts — if you use
AddDbContextPool, the interceptor is stateless, so it can safely be registered as a singleton instead of scoped:builder.Services.AddSingleton<BaseEntitySaveChangesInterceptor>();