Persistence — EF Core, Isolated DbContexts
The problem
Section titled “The problem”Every module in a modular framework needs audit trails, soft delete, and
tenant isolation. Without centralized support, developers end up copying
the same boilerplate: setting CreatedAt in every repository, writing
WHERE IsDeleted = false in every query, forgetting ModifiedBy on
updates. One missed filter and you surface deleted records or leak data
across tenants.
Granit eliminates this by intercepting EF Core’s SaveChanges pipeline
and auto-applying query filters. Application code deals with domain logic;
the framework handles the plumbing.
EF Core interceptors
Section titled “EF Core interceptors”Six SaveChangesInterceptor implementations run in sequence before
every SaveChanges / SaveChangesAsync call. This page covers the three you
meet first; see Interceptors for the full set.
flowchart LR
A[SaveChanges called] --> B[AuditedEntityInterceptor]
B --> C[VersioningInterceptor]
C --> D[ConcurrencyStampInterceptor]
D --> E[DomainEventDispatcherInterceptor]
E --> F[EntityLifecycleEventInterceptor]
F --> G[SoftDeleteInterceptor]
G --> H[Database]
SoftDeleteInterceptor runs last on purpose: it flips EntityState.Deleted to
Modified, which would hide the original state from every interceptor before
it.
1. AuditedEntityInterceptor
Section titled “1. AuditedEntityInterceptor”Audit tracking splits across two interfaces in Granit.Domain, so an entity can
opt into creation stamps without carrying modification stamps:
| Interface | Fields | Set when |
|---|---|---|
ICreationAuditedObject | CreatedAt, CreatedBy (+ TenantId if IMultiTenant) | Added |
IModificationAuditedObject | ModifiedAt, ModifiedBy | Modified |
The interceptor pivots on the interfaces, not on a base class — inheriting
FullAuditedEntity / FullAuditedAggregateRoot is a convenience, not a
requirement.
Timestamps come from the injected TimeProvider (never DateTime.Now).
User identity comes from ICurrentUserService. Tenant ID comes from
ICurrentTenant.
public class Invoice : AggregateRoot, ICreationAuditedObject, IModificationAuditedObject, IMultiTenant{ public Guid? TenantId { get; set; } public string Number { get; set; } = string.Empty; public decimal Amount { get; set; }
// These are set automatically — never assign them manually public DateTimeOffset CreatedAt { get; set; } public string CreatedBy { get; set; } = string.Empty; public DateTimeOffset? ModifiedAt { get; set; } public string? ModifiedBy { get; set; }}2. SoftDeleteInterceptor
Section titled “2. SoftDeleteInterceptor”Targets entities implementing ISoftDeletable. When EF Core detects a
Deleted state, the interceptor:
- Changes the state from
DeletedtoModified. - Sets
IsDeleted = true,DeletedAt, andDeletedBy. - The row stays in the database — no physical DELETE is issued.
public class Document : AggregateRoot, ISoftDeletable{ public string Title { get; set; } = string.Empty;
// Managed by the interceptor public bool IsDeleted { get; set; } public DateTimeOffset? DeletedAt { get; set; } public string? DeletedBy { get; set; }}3. VersioningInterceptor
Section titled “3. VersioningInterceptor”Targets entities implementing IVersioned (Granit.Domain). On insert:
- Assigns a
VersionId(stable GUID shared across all versions of the same logical entity). - Sets
Versiontomax(Version) + 1for thatVersionId.
This gives you an immutable version history without a separate history table.
Automatic query filters
Section titled “Automatic query filters”ApplyGranitConventions scans all entity types and registers one named
HasQueryFilter per applicable interface (EF Core 10). Each filter is
independent and individually bypassable:
| Interface | Filter key | SQL condition |
|---|---|---|
ISoftDeletable | SoftDelete | WHERE IsDeleted = false |
IActive | Active | WHERE Activated = true |
IMultiTenant | MultiTenant | WHERE TenantId = @currentTenantId |
IProcessingRestrictable | ProcessingRestrictable | WHERE IsProcessingRestricted = false |
IPublishable | Publishable | WHERE IsPublished = true |
IHasMergeTombstone | MergeTombstone | WHERE MergedIntoId IS NULL |
An entity implementing both ISoftDeletable and IMultiTenant gets two named
filters, both applied by EF Core as AND conditions:
WHERE "e"."IsDeleted" = FALSE AND "e"."TenantId" = @__tenantIdBypass a single filter for one query without touching the others:
// Only soft delete bypassed — multi-tenant still appliesvar rows = await db.Patients .IgnoreQueryFilters([GranitFilterNames.SoftDelete]) .ToListAsync(ct);Isolated DbContext checklist
Section titled “Isolated DbContext checklist”Every *.EntityFrameworkCore package that owns a DbContext must
follow this checklist. No exceptions — the architecture tests enforce it.
1. ProjectReference to Granit.Persistence
Section titled “1. ProjectReference to Granit.Persistence”<ProjectReference Include="..\Granit.Persistence\Granit.Persistence.csproj" />2. Derive from GranitDbContext
Section titled “2. Derive from GranitDbContext”Inherit GranitDbContext and pass ICurrentTenant plus an optional
IDataFilter to the base constructor:
internal sealed class InvoiceDbContext( DbContextOptions<InvoiceDbContext> options, ICurrentTenant currentTenant, IDataFilter? dataFilter = null) : GranitDbContext(options, currentTenant, dataFilter){ public DbSet<Invoice> Invoices => Set<Invoice>();}Deriving is what makes the tenant filter safe. EF Core only parameterises a
query-filter value when it reads it from a member of the DbContext instance.
A value captured from a constructor-injected service is inlined as a literal
into the compiled SQL and frozen for the lifetime of the cached model — request
A pins WHERE TenantId = 'A' and request B reuses the frozen plan. Because
CurrentTenantId is an instance property on GranitDbContext, EF Core emits
@ef_filter__CurrentTenantId and re-binds it on every command.
3. Override OnGranitModelCreating
Section titled “3. Override OnGranitModelCreating”OnModelCreating is sealed on the base class — the conventions pass is not
something a module can skip, reorder, or duplicate. Put your entity
configuration in OnGranitModelCreating instead:
protected override void OnGranitModelCreating(ModelBuilder modelBuilder){ modelBuilder.ApplyConfigurationsFromAssembly(typeof(InvoiceDbContext).Assembly);}4. Register the DbContext — AddGranitDbContext or AddGranitIsolatedDbContext
Section titled “4. Register the DbContext — AddGranitDbContext or AddGranitIsolatedDbContext”The framework ships two extensions; pick based on whether the module is tenant-isolated (data physically separated per tenant) or shared (one database, tenant-scoped via query filters):
// Non-tenant-isolated: the standard path — auto-wires interceptors and// applies UseGranitInterceptors (the 6 standard interceptors + any// IGranitAutoInterceptor registered by other modules, e.g. AuditingChangeTrackingInterceptor).public static IServiceCollection AddInvoicePersistence( this IServiceCollection services, Action<DbContextOptionsBuilder> configure){ services.AddGranitDbContext<InvoiceDbContext>(configure); return services;}For modules that must support all three tenant-isolation strategies
(SharedDatabase, DatabasePerTenant, SchemaPerTenant), use
AddGranitIsolatedDbContext — the host’s TenantIsolation:Strategy setting picks
the right factory at runtime, and an invalid value fails fast at startup:
// Tenant-isolated: ships ALL three strategies; appsettings picks one.services.AddGranitIsolatedDbContext<InvoiceDbContext>( configureShared: opts => opts.UseNpgsql(builder.Configuration.GetConnectionString("Invoicing")), configureDatabasePerTenant: (opts, tenantCs) => opts.UseNpgsql(tenantCs), configureSchemaPerTenant: opts => opts.UseNpgsql(builder.Configuration.GetConnectionString("Invoicing")), configureTenantSchema: schema => schema.NamingConvention = TenantSchemaNamingConvention.Prefix);Both helpers auto-wire AuditedEntityInterceptor, SoftDeleteInterceptor,
VersioningInterceptor, and any IGranitAutoInterceptor registered by another
module (e.g. AuditingChangeTrackingInterceptor) — manual AddInterceptors
calls aren’t needed.
5. DependsOn attribute
Section titled “5. DependsOn attribute”[DependsOn(typeof(GranitPersistenceModule))]public class InvoiceEntityFrameworkCoreModule : GranitModule{ // ...}6. No manual HasQueryFilter
Section titled “6. No manual HasQueryFilter”Already covered above. ApplyGranitConventions handles all standard
filters. Manual filters conflict with the named filter keys registered
by the framework.
7. TenantId type
Section titled “7. TenantId type”IMultiTenant entities use Guid? TenantId — never string, never
non-nullable Guid. The nullable type is required because host-level
entities (shared across tenants) legitimately have no tenant.
Data seeding
Section titled “Data seeding”Granit provides IDataSeedContributor for deterministic, idempotent
seed data:
public class InvoiceStatusSeedContributor : IDataSeedContributor{ public int Order => 100; // Controls execution sequence
public async Task SeedAsync( DataSeedContext context, CancellationToken cancellationToken) { var dbContext = context.ServiceProvider .GetRequiredService<InvoiceDbContext>();
if (await dbContext.InvoiceStatuses.AnyAsync(cancellationToken)) { return; // Idempotent — skip if data exists }
dbContext.InvoiceStatuses.AddRange( new InvoiceStatus { Code = "DRAFT", LabelEn = "Draft", LabelFr = "Brouillon" }, new InvoiceStatus { Code = "SENT", LabelEn = "Sent", LabelFr = "Envoyee" });
await dbContext.SaveChangesAsync(cancellationToken); }}Putting it all together
Section titled “Putting it all together”Here is what happens when you call SaveChangesAsync on an entity that
implements ICreationAuditedObject, IModificationAuditedObject, ISoftDeletable, and IMultiTenant:
On insert:
AuditedEntityInterceptorsetsCreatedAt,CreatedBy,TenantId.SoftDeleteInterceptor— no action (entity is not being deleted).- Row is inserted.
On update:
AuditedEntityInterceptorsetsModifiedAt,ModifiedBy.SoftDeleteInterceptor— no action.- Row is updated.
On delete:
AuditedEntityInterceptorsetsModifiedAt,ModifiedBy.SoftDeleteInterceptorchanges state toModified, setsIsDeleted,DeletedAt,DeletedBy.- Row is updated (not deleted).
On query:
- EF Core applies the combined query filter:
WHERE IsDeleted = false AND TenantId = @tenantId - Application code sees only active, non-deleted, tenant-scoped records.
Accessing data — Reader/Writer separation
Section titled “Accessing data — Reader/Writer separation”Application code does not use the DbContext directly. Each module exposes
IXxxReader (read-only) and IXxxWriter (mutations) interfaces. Endpoints
and handlers inject only the interface matching their intent:
// A read endpoint injects ONLY the Reader — no write access possibleprivate static async Task<Ok<List<BlobDescriptor>>> ListBlobsAsync( IBlobDescriptorReader reader, CancellationToken cancellationToken){ var blobs = await reader.FindAllAsync(cancellationToken).ConfigureAwait(false); return TypedResults.Ok(blobs);}This separation is enforced by architecture tests and gives you:
- Least privilege — a read endpoint cannot accidentally mutate data
- ISO 27001 audit — the DI graph shows which components have write access
- GDPR safety — Reader interfaces expose no
Deletemethods
See the CQRS concept for the full mental model, examples, and common mistakes to avoid.
Further reading
Section titled “Further reading”- Persistence reference — configuration options, migration commands, and API surface
- CQRS concept — Reader/Writer separation and compliance benefits
- Multi-Tenancy concept — tenant resolution and isolation strategies
- Compliance concept — how audit trails and soft delete support GDPR and ISO 27001