Interceptors — Audit, Soft Delete, Concurrency, Events
Five interceptors execute on every SaveChanges call, in this order:
| Order | Interceptor | Behavior |
|---|---|---|
| 1 | AuditedEntityInterceptor | Populates CreatedAt, CreatedBy, ModifiedAt, ModifiedBy, auto-generates Id, injects TenantId |
| 2 | VersioningInterceptor | Sets VersionId and Version on IVersioned entities |
| 3 | ConcurrencyStampInterceptor | Regenerates ConcurrencyStamp on IConcurrencyAware entities |
| 4 | DomainEventDispatcherInterceptor | Collects domain events before save, dispatches after commit |
| 5 | SoftDeleteInterceptor | Converts DELETE to UPDATE for ISoftDeletable entities |
AuditedEntityInterceptor
Section titled “AuditedEntityInterceptor”Resolves audit data from DI:
- Who:
ICurrentUserService.UserId(falls back to"system") - When:
IClock.Now(neverDateTime.UtcNow) - Id:
IGuidGenerator(sequential GUIDs for clustered indexes)
| Entity state | Action |
|---|---|
Added | Sets CreatedAt, CreatedBy. Auto-generates Id if Guid.Empty. Injects TenantId if IMultiTenant and null. |
Modified | Protects CreatedAt/CreatedBy from overwrite. Sets ModifiedAt, ModifiedBy. |
SoftDeleteInterceptor
Section titled “SoftDeleteInterceptor”Converts physical DELETE to soft delete:
DELETE FROM Patients WHERE Id = @id ↓ intercepted ↓UPDATE Patients SET IsDeleted = true, DeletedAt = @now, DeletedBy = @userId WHERE Id = @idOnly applies to entities implementing ISoftDeletable.
DomainEventDispatcherInterceptor
Section titled “DomainEventDispatcherInterceptor”Collects and dispatches domain events transactionally:
- SavingChanges — scans change tracker for
IDomainEventSourceentities, collects events, clears event lists - SavedChanges — dispatches events after commit via
IDomainEventDispatcher - SaveChangesFailed — discards events (transaction rolled back)
Uses AsyncLocal<List<IDomainEvent>> for thread safety across concurrent
SaveChanges calls in the same async flow.
Default dispatcher is NullDomainEventDispatcher (no-op). Granit.Wolverine
replaces it with real message bus dispatch.
VersioningInterceptor
Section titled “VersioningInterceptor”For IVersioned entities on EntityState.Added:
- Generates
VersionIdif empty (first version of a new entity) - Determines
Versionfrom change tracker (starting at 1) - Modified entities are untouched — versioning is explicit (create a new entity with same
VersionId)
ConcurrencyStampInterceptor
Section titled “ConcurrencyStampInterceptor”Prevents silent data loss from concurrent writes. When two users load the same entity and both save changes, the second write fails instead of overwriting the first.
Targets entities implementing IConcurrencyAware:
public class Order : AuditedEntity, IConcurrencyAware{ public string Reference { get; set; } = string.Empty; public OrderStatus Status { get; set; } public string ConcurrencyStamp { get; set; } = string.Empty;}| Entity state | Action |
|---|---|
Added | Sets ConcurrencyStamp to a new GUID string |
Modified | Regenerates ConcurrencyStamp with a new GUID string |
ApplyGranitConventions auto-discovers IConcurrencyAware entities and configures
ConcurrencyStamp as an EF Core concurrency token (VARCHAR(36),
.IsConcurrencyToken()). No manual Fluent API configuration needed.
EF Core includes the stamp in the WHERE clause on update:
UPDATE OrdersSET Status = @newStatus, ConcurrencyStamp = @newStampWHERE Id = @id AND ConcurrencyStamp = @originalStampIf the stamp in the database differs from the original value loaded by the entity,
EF Core throws DbUpdateConcurrencyException — mapped to HTTP 409 Conflict
by EfCoreExceptionStatusCodeMapper.
Connected vs disconnected updates
Section titled “Connected vs disconnected updates”Connected — entity loaded from the same DbContext. EF Core tracks
OriginalValue automatically; nothing extra to do:
var order = await db.Orders.FindAsync(orderId, ct);order!.Status = OrderStatus.Confirmed;await db.SaveChangesAsync(ct); // stamp checked automaticallyDisconnected (CQRS command, new DbContext) — the frontend sends the stamp it
received in the GET response. You must set OriginalValue explicitly before saving:
var order = await db.Orders.FindAsync(request.Id, ct);order!.Status = request.NewStatus;
// Tell EF Core what the client believes the current stamp isdb.Entry(order).Property(e => e.ConcurrencyStamp).OriginalValue = request.ConcurrencyStamp;
await db.SaveChangesAsync(ct); // throws DbUpdateConcurrencyException on mismatchUse IConcurrencyStampRequest as a DTO convention:
public sealed record UpdateOrderStatusRequest( Guid Id, OrderStatus NewStatus, string ConcurrencyStamp) : IConcurrencyStampRequest;See also
Section titled “See also”- Persistence overview — setup, isolated DbContext, host-owned migrations
- Query filters — named filters, runtime bypass, translations
- Zero-downtime migrations — Expand & Contract pattern