Interceptors & Runtime
The Problem
Section titled “The Problem”Several fields on your entities need to be set automatically when saving to the database:
- ID generation: New entities need a Guid7 (UUID v7) assigned before insert
- Audit timestamps:
CreatedAt,UpdatedAt,CreatedBy,UpdatedBymust be stamped - Tenant ID: Multi-tenant entities need
TenantIdset from the current request context
Doing this manually in every Create() call or service method is error-prone — forget one, and you have bad data.
The Solution: Interceptors
Section titled “The Solution: Interceptors”EF Core interceptors run automatically before SaveChanges. They inspect every entity being saved and populate fields as needed. The source generator registers them for you.
Where the ID comes from
Section titled “Where the ID comes from”Not from an interceptor. The generated entity assigns it in its constructor:
// ═══ In {Entity}.Traits.g.cs, for an [Entity<Guid>] ═══public Guid PersistenceId { get; set; } = Guid.CreateVersion7();The value is therefore final before EF Core sees the entity, which is what lets AuditLogInterceptor
record a real EntityId at SavingChanges time instead of a temporary one.
For int / long keys nothing is assigned: the database generates the value, and an Added entity
carries EF’s temporary key until the insert completes. For string keys the caller provides it.
Why UUID v7. A v4 GUID is random, so every insert lands at a random position in a clustered index and splits pages. A v7 embeds a millisecond timestamp in its first 48 bits, so new values sort after old ones and inserts append — the behaviour of an auto-increment key, without giving up global uniqueness.
⚠️ SQL Server orders GUIDs differently — it compares the last six bytes first, so a v7 does not
sort in insert order there. Guid7.NewForSqlServer() produces a byte-shuffled value that does, and
the generated entity does not use it: the constructor emits Guid.CreateVersion7() whatever the
provider is. Applying it would need the entity to know which database it will land in, and with more
than one database in a host that is not a static fact. A Guid7Interceptor used to be registered for
this and never ran — it only assigned when the value was Guid.Empty, and the constructor had already
filled it — so it was removed rather than left as a no-op on every save.
AuditingInterceptor
Section titled “AuditingInterceptor”What it does
Section titled “What it does”Populates audit fields on entities that implement IAuditable:
| Entity State | CreatedAt | CreatedBy | UpdatedAt | UpdatedBy |
|---|---|---|---|---|
Added | Set to now | Set from ICurrentUser | Set to now | Set from ICurrentUser |
Modified | Preserved | Preserved | Updated to now | Updated from ICurrentUser |
Dependencies
Section titled “Dependencies”new AuditingInterceptor(TimeProvider.System, currentUser)TimeProvider: Abstraction overDateTimeOffset.UtcNow. UseTimeProvider.Systemin production. In tests, inject a fakeTimeProviderfor deterministic timestamps.ICurrentUser: Optional. If null, the*Byfields remain null. When registered,CreatedBy/UpdatedByis set fromICurrentUser.Id— the property isId, and it is an empty string for an anonymous user, not null.
TenantInterceptor
Section titled “TenantInterceptor”What it does
Section titled “What it does”For multi-tenant applications: automatically sets TenantId on entities that implement ITenantEntity when they are first saved.
| Condition | Action |
|---|---|
Entity is Added + implements ITenantEntity + TenantId is empty | Set from ITenantContext.TenantId |
TenantId already has a value | Skip (don’t overwrite) |
Entity is Modified or Deleted | Skip (tenant is immutable after creation) |
This prevents a common bug: forgetting to set TenantId when creating an entity, which would make it invisible to tenant-scoped queries.
Value Converters
Section titled “Value Converters”Value converters transform property values between your C# types and the database.
ShortGuidConverter
Section titled “ShortGuidConverter”Converts a Guid to a 22-character URL-safe Base64 string. Useful for public-facing IDs in URLs.
// In entity configurationbuilder.Property(e => e.ExternalId).HasConversion<ShortGuidConverter>();
// C# value: 3F2504E0-4F89-11D3-9A0C-0305E82C3301// DB value: "4AUlP4lP00GaDCMF6CwzAQ" (22 characters)When to use: Public API responses, URL slugs, anywhere you want a shorter, URL-safe representation of a GUID.
OpaqueIdConverter
Section titled “OpaqueIdConverter”Converts a long to an obfuscated string. Prevents enumeration attacks where an attacker guesses sequential IDs (/users/1, /users/2, /users/3…).
builder.Property(e => e.PublicId).HasConversion(new OpaqueIdConverter("my-secret-salt"));
// C# value: 42// DB value: "kN7xPm"When to use: Any integer ID exposed to end users where you don’t want them to infer counts or guess other IDs.
| Converter | C# Type | DB Type | Nullable Variant |
|---|---|---|---|
ShortGuidConverter | Guid | string(22) | NullableShortGuidConverter |
OpaqueIdConverter | long | string | NullableOpaqueIdConverter |
Interceptor Registration
Section titled “Interceptor Registration”The generated Add{Boundary}DbContext(...) registers them for you, from what the boundary’s entities
declare:
| What the boundary contains | Interceptor registered |
|---|---|
Any [Auditable] | AuditingInterceptor |
Any ITenantEntity | TenantInterceptor |
Any [SoftDelete] | SoftDeleteInterceptor — turns a delete into a flag at save time, on every path |
Any [HasOwner] | OwnershipInterceptor |
Any [Audited] | AuditLogInterceptor — the append-only __AuditLog row, in the same transaction |
Any [RollUp<T>] | RollUpInterceptor — keeps the parent’s stored aggregate current |
There is no ID interceptor: the entity assigns its own PersistenceId — see Where the ID comes from.
You do not register these manually unless you want to change their behaviour.