Skip to content

Persistence — EF Core 10, Isolated DbContexts

Granit.Persistence provides the data access layer for the framework. It automates ISO 27001 audit trails, GDPR-compliant soft delete, optimistic concurrency control, domain event dispatching, entity versioning, and multi-tenant query filtering — all through EF Core interceptors and conventions. No manual WHERE clauses, no forgotten audit fields, no silent data overwrites.

  • DirectoryGranit.Persistence/ Abstractions — ICurrentTenant filters, data seeding contracts, no EF references
    • DirectoryGranit.Persistence.EntityFrameworkCore/ Interceptors, ApplyGranitConventions, AddGranitDbContext, AddGranitIsolatedDbContext
      • DirectoryGranit.Persistence.EntityFrameworkCore.Hosting/ Tenant provisioning, host orchestration
      • DirectoryGranit.Persistence.EntityFrameworkCore.Migrations/ Zero-downtime batch migrations (Expand & Contract)
      • DirectoryGranit.Persistence.EntityFrameworkCore.Postgres/ PostgreSQL provider, advisory locks, schema activator
      • DirectoryGranit.Persistence.EntityFrameworkCore.SqlServer/ SQL Server provider
PackageRoleDepends on
Granit.PersistenceAbstractions only — no EF reference. Tenant filter contracts, data seeding contractsGranit
Granit.Persistence.EntityFrameworkCoreInterceptors, ApplyGranitConventions, AddGranitDbContext, AddGranitIsolatedDbContextGranit.Persistence
Granit.Persistence.EntityFrameworkCore.HostingTenant provisioning, host orchestrationGranit.Persistence.EntityFrameworkCore
Granit.Persistence.EntityFrameworkCore.MigrationsExpand & Contract migrations with batch processing; Wolverine handlers ship here tooGranit.Persistence.EntityFrameworkCore
Granit.Persistence.EntityFrameworkCore.PostgresPostgreSQL provider, advisory locks, schema activatorGranit.Persistence.EntityFrameworkCore
Granit.Persistence.EntityFrameworkCore.SqlServerSQL Server providerGranit.Persistence.EntityFrameworkCore
graph TD
    P[Granit.Persistence] --> C[Granit]
    P --> T[Granit.Timing]
    P --> G[Granit.Guids]
    P --> S[Granit.Users]
    EFC[Granit.Persistence.EntityFrameworkCore] --> P
    PM[Granit.Persistence.EFC.Migrations] --> EFC
    PM --> T
    PMW --> W[Granit.Wolverine]
[DependsOn(typeof(GranitPersistenceModule))]
public class AppModule : GranitModule { }

Registers all five interceptors and IDataFilter.

Every *.EntityFrameworkCore package that owns a DbContext must follow this checklist. No exceptions.

public class AppointmentDbContext(
DbContextOptions<AppointmentDbContext> options,
ICurrentTenant? currentTenant = null,
IDataFilter? dataFilter = null)
: DbContext(options)
{
public DbSet<Appointment> Appointments => Set<Appointment>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppointmentDbContext).Assembly);
// MANDATORY — applies all query filters (soft delete, multi-tenant, active, etc.)
modelBuilder.ApplyGranitConventions(currentTenant, dataFilter);
}
}

Registration in the module:

public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddGranitDbContext<AppointmentDbContext>(options =>
{
options.UseNpgsql(context.Configuration.GetConnectionString("Default"));
});
}

AddGranitDbContext<T> handles interceptor wiring automatically — it calls UseGranitInterceptors(sp) internally with ServiceLifetime.Scoped, and pre-seeds HostDbSchema so any module that opts into a dual-scope (host + tenant) layout lands its host tables under the correct schema by default.

AddGranitDbContext vs AddGranitIsolatedDbContext

Section titled “AddGranitDbContext vs AddGranitIsolatedDbContext”

Two registration extensions, two intents:

ExtensionUse forBehaviour
AddGranitDbContext<T>(opts)Default — dual-scope or host-only contextsRuns interceptors, applies host schema, leaves tenant resolution to ApplyGranitConventions
AddGranitIsolatedDbContext<T>(opts)Strictly tenant-isolated modules (BlobStorage, Privacy, Encryption-bound contexts, etc.)Forces every query through ICurrentTenant, throws if no tenant is active, refuses host-side bypass

Seven framework modules now register their DbContext via AddGranitIsolatedDbContext (PR #2021): the safety net is on by default for anything storing per-tenant blobs, PII, or encrypted data. PR #2028 fixed an infinite-recursion bug in the non-generic overload — make sure you are on the patched version if you use it.

[Encrypted] fields can only live on entities owned by an IsolatedDbContext — architecture tests fail the build otherwise (PR #2062).

Granit modules ship entity configurations, not migrations. Why? Because the host application — not the framework — decides how to organize its database:

  • Which modules share a DbContext? A simple app might put everything in one AppDbContext. A larger system might split SecurityDbContext and BusinessDbContext.
  • When do migrations run? At startup (MigrateAsync) for dev/staging, or via idempotent SQL scripts in CI/CD for production.
  • How do schema changes deploy? Simple ALTER TABLE for single-instance apps, or Expand & Contract for zero-downtime K8s deployments.

The framework cannot make these decisions — only the host can. So Granit modules expose their entity configurations via Configure{Module}Module() and let the host generate and own all migration files.

Every *.EntityFrameworkCore module exposes a public ModelBuilder extension. The host calls these in its own DbContext’s OnModelCreating:

public class AppDbContext(
DbContextOptions<AppDbContext> options,
ICurrentTenant? currentTenant = null,
IDataFilter? dataFilter = null)
: DbContext(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Include Granit module tables in this DbContext
modelBuilder.ConfigureSettingsModule();
modelBuilder.ConfigureBackgroundJobsModule();
modelBuilder.ConfigureNotificationsModule();
modelBuilder.ConfigureWorkflowModule();
// MANDATORY — always call last
modelBuilder.ApplyGranitConventions(currentTenant, dataFilter);
}
}

The module’s internal DbContext (used at runtime by module services) calls the same Configure{Module}Module() method — guaranteeing that migration-time and runtime EF models are always identical.

Each module defines a Granit{Module}DbProperties static class with configurable table prefix and schema:

// Override before ConfigureServices — EF Core caches the model after first use
GranitBackgroundJobsDbProperties.DbTablePrefix = "background_jobs_";
GranitBackgroundJobsDbProperties.DbSchema = "core";

Default prefixes follow the convention module name in snake_case + _:

ModuleDefault prefixExample table
BackgroundJobsbackground_jobs_background_jobs_background_jobs
Settingssettings_settings_setting_records
Workflowworkflow_workflow_transition_records
Auditingaudit_log_audit_log_log_entries
Notificationsnotifications_notifications_user_notifications
Webhookswebhooks_webhooks_subscriptions
BlobStorageblob_storage_blob_storage_descriptors
Featuresfeatures_features_overrides
Localizationlocalization_localization_overrides
AIai_ai_workspaces
ApiKeysapi_keys_api_keys_api_keys
Authorizationauthorization_authorization_permission_grants
OpenIddictopeniddict_openiddict_users
Identityidentity_identity_user_cache_entries
DataExchangedata_exchange_data_exchange_import_jobs
QueryEnginequery_engine_query_engine_saved_views
Templatingtemplating_templating_revisions
Timelinetimeline_timeline_entries

Two approaches, depending on your deployment model:

Best for single-instance apps, dev, and staging environments. The application creates or updates the schema on boot:

// Program.cs — after builder.Build()
await using var scope = app.Services.CreateAsyncScope();
var factory = scope.ServiceProvider
.GetRequiredService<IDbContextFactory<AppDbContext>>();
await using var db = await factory.CreateDbContextAsync();
await db.Database.MigrateAsync();

This also works with WebApplicationFactory + Testcontainers in integration tests.

Register seed contributors that run at application startup:

public class CountryDataSeedContributor(AppDbContext db) : IHostDataSeedContributor
{
public async Task SeedAsync(DataSeedContext context, CancellationToken cancellationToken)
{
if (await db.Countries.AnyAsync(cancellationToken).ConfigureAwait(false))
return;
db.Countries.AddRange(
new Country { Code = "BE", Name = "Belgium" },
new Country { Code = "FR", Name = "France" });
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}

Register with services.AddTransient<IHostDataSeedContributor, CountryDataSeedContributor>() — or use ITenantDataSeedContributor if the seed depends on ICurrentTenant and must run inside each tenant’s scope (PR #942 split host vs tenant seeding so cross-tenant data is never mixed in).

DataSeedingHostedService orchestrates all contributors at startup. Errors are logged but do not block application start.

Hard-delete soft-deleted records past retention (ISO 27001):

await dbContext.PurgeSoftDeletedBeforeAsync<Patient>(
cutoff: DateTimeOffset.UtcNow.AddYears(-3),
batchSize: 1000,
cancellationToken);

Uses ExecuteDeleteAsync() with IgnoreQueryFilters([GranitFilterNames.SoftDelete]) — only the soft-delete filter is bypassed; the multi-tenant filter remains active, ensuring purge operations stay scoped to the current tenant. Provider-agnostic, bypasses interceptors.

Granit.Persistence supports three tenant isolation topologies:

StrategyIsolationComplexityUse case
SharedDatabaseQuery filter (TenantId column)LowMost applications
SchemaPerTenantPostgreSQL schema per tenantMediumStronger isolation, shared infra
DatabasePerTenantSeparate database per tenantHighMaximum isolation, regulated data

Configuration:

{
"TenantIsolation": {
"Strategy": "SharedDatabase"
},
"MultiTenancy:TenantSchema": {
"NamingConvention": "TenantId",
"Prefix": "tenant_"
}
}

Built-in schema activators: PostgresqlTenantSchemaActivator, MySqlTenantSchemaActivator, OracleTenantSchemaActivator.

Sometimes a separate package needs to augment a module’s EF Core model without the module taking a dependency on that package’s technology. The canonical case is spatial: a PostGIS package adds a generated geography(Point) column and a GiST index to a module’s address table, while NetTopologySuite stays entirely out of the base module. This is the mechanism behind the address platform’s planned spatial opt-in.

IGranitModelExtension is the hook:

namespace Granit.Persistence.EntityFrameworkCore;
public interface IGranitModelExtension
{
void Apply(ModelBuilder modelBuilder, DbContext context);
}

Register implementations as singletons in DI. GranitDbContext resolves the registered set from the application service provider and applies it at the very end of model building — after the module’s own OnGranitModelCreating and after ApplyGranitConventions — so an extension gets the final say over the model surface.

Because the same registered set is applied to every Granit DbContext in the application, each implementation sees a model it may not target. So an extension must self-guard on both the provider and the entity type before touching the builder:

public sealed class AddressGeographyModelExtension : IGranitModelExtension
{
public void Apply(ModelBuilder modelBuilder, DbContext context)
{
if (!context.Database.IsNpgsql()) return; // provider-gate
if (modelBuilder.Model.FindEntityType(typeof(PartyAddress)) is null) return; // entity-gate
modelBuilder.Entity<PartyAddress>()
.Property<Point>("Location")
.HasComputedColumnSql("...", stored: true);
// + a GiST index on "Location"
}
}
// registration — singleton, read once when the model is built
services.AddSingleton<IGranitModelExtension, AddressGeographyModelExtension>();

The hook only shapes the model. The host still owns any migration: generating and applying the DDL for the new column and index is part of host-owned migrations, exactly like every other schema change.

Distinct extension sets, distinct cached models

Section titled “Distinct extension sets, distinct cached models”

EF Core caches one built model per context type. Two contexts of the same type configured with different extension sets — a multi-provider host, or independent tests in one process — would otherwise share whichever model was built first. GranitModelCacheKeyFactory folds the registered extension signature into the model cache key so each configuration keeps its own cached model; it is auto-wired by UseGranitInterceptors() (via ReplaceService<IModelCacheKeyFactory>) and is equivalent to EF’s default key when no extensions are registered.

CategoryKey typesPackage
ModuleGranitPersistenceModuleGranit.Persistence
InterceptorsAuditedEntityInterceptor, VersioningInterceptor, ConcurrencyStampInterceptor, DomainEventDispatcherInterceptor, SoftDeleteInterceptorGranit.Persistence
ExtensionsAddGranitPersistence(), AddGranitDbContext<T>(), UseGranitInterceptors(), ApplyGranitConventions()Granit.Persistence
Data seedingIHostDataSeedContributor, ITenantDataSeedContributor, DataSeedContext (PR #942 split the legacy IDataSeedContributor)Granit.Persistence
PurgingISoftDeletePurgeTarget, PurgeSoftDeletedBeforeAsync()Granit.Persistence
Query extensionsIncludeTranslations(), WhereTranslation(), OrderByTranslation()Granit.Persistence
Multi-tenancyTenantIsolationStrategy, ITenantSchemaProvider, ITenantConnectionStringProvider, ITenantSchemaActivator, IsolatedDbContextMarkerGranit.Persistence
Tenant provisioningITenantProvisioner, AutoTenantProvisionerGranit.Persistence.EntityFrameworkCore.Hosting
Host migrationsConfigure{Module}Module(), Granit{Module}DbPropertiesEach *.EntityFrameworkCore package
MigrationsMigrationPhase, MigrationCycleAttribute, IMigrationCycleRegistry, BatchMigrationDelegateGranit.Persistence.EntityFrameworkCore.Migrations
Wolverine dispatchGranitPersistenceMigrationsWolverineModuleGranit.Persistence.Migrations.Wolverine
Model extensionsIGranitModelExtension, GranitModelCacheKeyFactoryGranit.Persistence.EntityFrameworkCore

For unit tests that need query filters and interceptors, use SQLite in-memory:

[Fact]
public async Task Soft_deleted_entities_are_filtered_out()
{
// Arrange
await using var context = CreateSqliteContext<PatientDbContext>();
var patient = new Patient { Name = "Jane Doe" };
context.Patients.Add(patient);
await context.SaveChangesAsync();
// Act — soft delete
patient.IsDeleted = true;
await context.SaveChangesAsync();
// Assert — filtered by default
var patients = await context.Patients.ToListAsync();
patients.ShouldBeEmpty();
// Assert — visible when filter is disabled
var all = await context.Patients.IgnoreQueryFilters([GranitFilterNames.SoftDelete]).ToListAsync();
all.Count.ShouldBe(1);
}

For integration tests with real PostgreSQL, use Testcontainers to get full fidelity (JSON columns, concurrent transactions, schema isolation).