Skip to content

Interceptors & Runtime

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, UpdatedBy must be stamped
  • Tenant ID: Multi-tenant entities need TenantId set 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.

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.

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.

Populates audit fields on entities that implement IAuditable:

Entity StateCreatedAtCreatedByUpdatedAtUpdatedBy
AddedSet to nowSet from ICurrentUserSet to nowSet from ICurrentUser
ModifiedPreservedPreservedUpdated to nowUpdated from ICurrentUser
new AuditingInterceptor(TimeProvider.System, currentUser)
  • TimeProvider: Abstraction over DateTimeOffset.UtcNow. Use TimeProvider.System in production. In tests, inject a fake TimeProvider for deterministic timestamps.
  • ICurrentUser: Optional. If null, the *By fields remain null. When registered, CreatedBy/UpdatedBy is set from ICurrentUser.Id — the property is Id, and it is an empty string for an anonymous user, not null.

For multi-tenant applications: automatically sets TenantId on entities that implement ITenantEntity when they are first saved.

ConditionAction
Entity is Added + implements ITenantEntity + TenantId is emptySet from ITenantContext.TenantId
TenantId already has a valueSkip (don’t overwrite)
Entity is Modified or DeletedSkip (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 transform property values between your C# types and the database.

Converts a Guid to a 22-character URL-safe Base64 string. Useful for public-facing IDs in URLs.

// In entity configuration
builder.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.

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.

ConverterC# TypeDB TypeNullable Variant
ShortGuidConverterGuidstring(22)NullableShortGuidConverter
OpaqueIdConverterlongstringNullableOpaqueIdConverter

The generated Add{Boundary}DbContext(...) registers them for you, from what the boundary’s entities declare:

What the boundary containsInterceptor registered
Any [Auditable]AuditingInterceptor
Any ITenantEntityTenantInterceptor
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.