Skip to content

Interceptors — Audit, Soft Delete, Concurrency, Events

Five interceptors execute on every SaveChanges call, in this 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
5SoftDeleteInterceptorConverts DELETE to UPDATE for ISoftDeletable entities

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.

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 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.

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;