Skip to content

Interceptors — Audit, Soft Delete, Concurrency, Events

Six interceptors execute on every SaveChanges call, in this registration order:

OrderInterceptorBehavior
1AuditedEntityInterceptorPopulates CreatedAt, CreatedBy, ModifiedAt, ModifiedBy, auto-generates Id, injects TenantId
2VersioningInterceptorSets VersionId and Version on IVersioned entities
3ConcurrencyStampInterceptorRegenerates ConcurrencyStamp on IConcurrencyAware entities
4DomainEventDispatcherInterceptorCollects domain events before save, dispatches after commit
5EntityLifecycleEventInterceptorEmits lifecycle events for IEmitEntityLifecycleEvents / IHasEntityEto<TEto> entities
6SoftDeleteInterceptorConverts DELETE to UPDATE for ISoftDeletable entities

Modules can add their own through IGranitAutoInterceptorGranit.Auditing (AuditingChangeTrackingInterceptor) and Granit.Identity (UserLookupHashInterceptor) both plug in that way, and UseGranitInterceptors appends them after the six above.

Two dispatch timings, chosen deliberately:

  • SavingChanges — distributed ETOs (IIntegrationEvent) are dispatched before the transaction commits, so Wolverine writes outbox envelopes atomically with the data change.
  • SavedChanges — local domain events (IDomainEvent) are dispatched after commit, so handlers can safely read committed data.

Inter-callback state is keyed on DbContext.ContextId.InstanceId rather than AsyncLocal, which is what makes the interceptor safe to register as a singleton.

Resolves audit data from DI:

  • Who: ICurrentUserService.UserId (falls back to "system")
  • When: IClock.Now (never DateTime.UtcNow)
  • Id: IGuidGenerator (sequential GUIDs for clustered indexes)
Entity stateAction
AddedSets CreatedAt, CreatedBy. Auto-generates Id if Guid.Empty. Injects TenantId if IMultiTenant and null.
ModifiedProtects CreatedAt/CreatedBy from overwrite. Sets ModifiedAt, ModifiedBy.

The interceptor pivots on interfaces, not on a base class:

  • ICreationAuditedObject (CreatedAt, CreatedBy) — populated on Added
  • IModificationAuditedObject (ModifiedAt, ModifiedBy) — populated on Modified

The CreationAuditedEntity / AuditedEntity / aggregate-root base classes implement these interfaces, so the common case needs nothing extra. But an entity that cannot inherit those base classes still participates by implementing the interfaces directly. The canonical case is LocalIdentity, forced to extend ASP.NET Identity’s IdentityUser<Guid> (single inheritance is already spent), which implements both audit interfaces. Id generation stays gated on the Entity hierarchy — types that own their own key (like IdentityUser) keep it.

Converts physical DELETE to soft delete:

DELETE FROM Patients WHERE Id = @id
↓ intercepted ↓
UPDATE Patients SET IsDeleted = true, DeletedAt = @now, DeletedBy = @userId WHERE Id = @id

Only applies to entities implementing ISoftDeletable.

Collects and dispatches domain events transactionally:

  1. SavingChanges — scans change tracker for IDomainEventSource entities, collects events, clears event lists
  2. SavedChanges — dispatches events after commit via IDomainEventDispatcher
  3. SaveChangesFailed — discards events (transaction rolled back)

Uses a static ConcurrentDictionary keyed by DbContext.ContextId.InstanceId for thread safety across concurrent SaveChanges calls (avoids AsyncLocal cross-request leaks on singleton interceptors).

Default dispatcher is NullDomainEventDispatcher (no-op). Granit.Events.Wolverine replaces it with WolverineDomainEventDispatcher which routes events through the Wolverine message bus.

For IVersioned entities on EntityState.Added:

  • Generates VersionId if empty (first version of a new entity)
  • Determines Version from change tracker (starting at 1)
  • Modified entities are untouched — versioning is explicit (create a new entity with same VersionId)

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 stateAction
AddedSets ConcurrencyStamp to a new GUID string
ModifiedRegenerates 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 Orders
SET Status = @newStatus, ConcurrencyStamp = @newStamp
WHERE Id = @id AND ConcurrencyStamp = @originalStamp

If 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 — 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 automatically

Disconnected (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 is
db.Entry(order).Property(e => e.ConcurrencyStamp).OriginalValue = request.ConcurrencyStamp;
await db.SaveChangesAsync(ct); // throws DbUpdateConcurrencyException on mismatch

Use IConcurrencyStampRequest as a DTO convention:

public sealed record UpdateOrderStatusRequest(
Guid Id,
OrderStatus NewStatus,
string ConcurrencyStamp) : IConcurrencyStampRequest;

AuditedEntityInterceptor and SoftDeleteInterceptor emit counters via PersistenceMetrics (meter name: Granit.Persistence):

MetricTagsDescription
granit.persistence.entity.purgedtenant_idSoft-deleted entities permanently purged
granit.persistence.cross_tenant_queryentity_name, origin, tenant_idA query that bypassed the multi-tenant filter

tenant_id is coalesced to "global" when no tenant is active. The cross_tenant_query counter is the interesting one to alert on: origin distinguishes host_endpoint (a route explicitly marked host-scoped), explicit (a deliberate QueryAcrossTenants call), and implicit_unsignaled — a bypass that happened with no host-access signal at all, which is the shape of a tenant-isolation bug.