Auditing base for EF Core entities. Inherit one class and get automatic CreatedAt/UpdatedAt/UserId tracking,
soft delete, optimistic concurrency via row versioning, bulk update/delete helpers, and a SaveChanges interceptor
that enforces correct audit method usage at runtime.
Targets net8.0, net9.0, and net10.0.
- Features
- Installation
- Getting Started
- AuditEntityBase
- Registering the Interceptor
- Soft Delete Query Filter
- Bulk Operations
- Concurrency Handling
- SyncAuditBase
- Automatic audit fields —
CreatedAt,CreatedByUserId,UpdatedAt,UpdatedByUserId,Deleted,Versionmaintained on every entity that inheritsAuditEntityBase - Enforced audit methods — a
SaveChangesinterceptor throws at runtime if a modified entity'sVersionwas not incremented, meaning someone bypassedMarkAsUpdated/MarkAsDeleted - Soft delete —
Deletedflag withMarkAsDeletedand a global query filter that hides deleted rows transparently - Optimistic concurrency —
Versionis decorated with[ConcurrencyCheck]; EF Core raises a concurrency exception on conflict automatically - Bulk helpers —
ExecuteSoftDeleteAsyncandExecuteUpdateAndMarkUpdatedAsynctranslate directly toExecuteUpdateAsyncdatabase calls while still maintaining correct audit fields - In-memory batch delete —
MarkAsDeletedoverload onIEnumerable<T>for cases where entities are already tracked
dotnet add package Pandatech.EFCore.AuditBaseInherit AuditEntityBase in your entity:
public class Product : AuditEntityBase
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}That's enough to gain all audit properties. Wire up the interceptor and query filter as shown below.
CreatedAt DateTime Set to UtcNow on construction. Never modified after that.
CreatedByUserId long? Required on construction (required init). Never modified after that.
UpdatedAt DateTime? Set by MarkAsUpdated / MarkAsDeleted.
UpdatedByUserId long? Set by MarkAsUpdated / MarkAsDeleted.
Deleted bool Set to true by MarkAsDeleted.
Version int Starts at 1. Incremented by every MarkAsUpdated / MarkAsDeleted call.
product.MarkAsUpdated(userId);
// or with an explicit timestamp:
product.MarkAsUpdated(userId, updatedAt: syncedTime);
await dbContext.SaveChangesAsync(ct);product.MarkAsDeleted(userId);
await dbContext.SaveChangesAsync(ct);Both methods increment Version. The interceptor validates this increment on every SaveChanges call — if you modify
an audited entity's properties directly without calling MarkAsUpdated, the interceptor throws:
InvalidOperationException: Entity 'Product' was modified without calling MarkAsUpdated or MarkAsDeleted.
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseNpgsql(connectionString)
.UseAuditBaseValidatorInterceptor());UseAuditBaseValidatorInterceptor adds AuditPropertyValidationInterceptor to the context. It hooks into both
SavingChanges and SavingChangesAsync.
Apply a global query filter in OnModelCreating to exclude soft-deleted rows from all queries automatically:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.FilterOutDeletedMarkedObjects();
}FilterOutDeletedMarkedObjects iterates every entity type that inherits AuditEntityBase and applies
.HasQueryFilter(e => !e.Deleted) to each one via expression trees.
To include deleted rows in a specific query:
var all = await dbContext.Products.IgnoreQueryFilters().ToListAsync(ct);Soft-deletes all rows matching a query in a single UPDATE statement. Does not load entities into memory.
await dbContext.Products
.Where(p => p.Price > 100)
.ExecuteSoftDeleteAsync(userId, ct: ct);Translates to:
UPDATE products
SET deleted = true, updated_at = NOW(), updated_by_user_id = @userId, version = version + 1
WHERE price > 100Updates arbitrary properties while automatically maintaining UpdatedAt, UpdatedByUserId, and Version:
await dbContext.Products
.Where(p => p.Price > 100)
.ExecuteUpdateAndMarkUpdatedAsync(
userId,
x => x.SetProperty(p => p.Price, p => p.Price * 0.9m),
ct);For already-tracked entities where you want to call SaveChanges once after marking several:
var products = await dbContext.Products.Where(p => p.Price > 100).ToListAsync(ct);
products.MarkAsDeleted(userId);
await dbContext.SaveChangesAsync(ct);Optimistic locking note:
ExecuteSoftDeleteAsyncandExecuteUpdateAndMarkUpdatedAsyncbypass EF Core's change tracker and do not raise concurrency exceptions. They incrementVersionunconditionally. Use them when bulk throughput matters more than per-row conflict detection.
Version is decorated with [ConcurrencyCheck]. When two requests load the same entity and both call SaveChanges,
the second one gets a DbUpdateConcurrencyException because the Version in the database no longer matches what was
read.
try
{
product.MarkAsUpdated(userId);
await dbContext.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
// Reload and retry, or return a 409 to the caller.
}SyncAuditBase copies audit fields from one entity instance to another while bypassing the interceptor. It is
intended for internal synchronization scenarios (e.g., merging detached entity state) and should not be used in
normal update flows.
target.SyncAuditBase(source);
await dbContext.SaveChangesAsync(ct);Setting IgnoreInterceptor = true via SyncAuditBase suppresses validation for all modified entities in that
SaveChanges call, so use it only when you are certain the audit state being applied is already correct.
MIT