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.
Package structure
Section titled “Package structure”DirectoryGranit.Persistence/ Abstractions —
ICurrentTenantfilters, data seeding contracts, no EF referencesDirectoryGranit.Persistence.EntityFrameworkCore/ Interceptors,
ApplyGranitConventions,AddGranitDbContext,AddGranitIsolatedDbContextDirectoryGranit.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
- …
| Package | Role | Depends on |
|---|---|---|
Granit.Persistence | Abstractions only — no EF reference. Tenant filter contracts, data seeding contracts | Granit |
Granit.Persistence.EntityFrameworkCore | Interceptors, ApplyGranitConventions, AddGranitDbContext, AddGranitIsolatedDbContext | Granit.Persistence |
Granit.Persistence.EntityFrameworkCore.Hosting | Tenant provisioning, host orchestration | Granit.Persistence.EntityFrameworkCore |
Granit.Persistence.EntityFrameworkCore.Migrations | Expand & Contract migrations with batch processing; Wolverine handlers ship here too | Granit.Persistence.EntityFrameworkCore |
Granit.Persistence.EntityFrameworkCore.Postgres | PostgreSQL provider, advisory locks, schema activator | Granit.Persistence.EntityFrameworkCore |
Granit.Persistence.EntityFrameworkCore.SqlServer | SQL Server provider | Granit.Persistence.EntityFrameworkCore |
Dependency graph
Section titled “Dependency graph”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.
[DependsOn(typeof(GranitPersistenceMigrationsModule))]public class AppModule : GranitModule{ public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitPersistenceMigrations(context.Builder, options => { options.UseNpgsql(context.Configuration.GetConnectionString("Migrations")); }); }}[DependsOn(typeof(GranitPersistenceMigrationsWolverineModule))]public class AppModule : GranitModule { }Replaces Channel<T> dispatcher with durable Wolverine outbox — no batch is lost on failure.
Isolated DbContext pattern
Section titled “Isolated DbContext pattern”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:
| Extension | Use for | Behaviour |
|---|---|---|
AddGranitDbContext<T>(opts) | Default — dual-scope or host-only contexts | Runs 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).
Host-owned migrations
Section titled “Host-owned migrations”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 splitSecurityDbContextandBusinessDbContext. - 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 TABLEfor 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.
How it works
Section titled “How it works”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.
DbProperties — table naming control
Section titled “DbProperties — table naming control”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 useGranitBackgroundJobsDbProperties.DbTablePrefix = "background_jobs_";GranitBackgroundJobsDbProperties.DbSchema = "core";Default prefixes follow the convention module name in snake_case + _:
| Module | Default prefix | Example table |
|---|---|---|
BackgroundJobs | background_jobs_ | background_jobs_background_jobs |
Settings | settings_ | settings_setting_records |
Workflow | workflow_ | workflow_transition_records |
Auditing | audit_log_ | audit_log_log_entries |
Notifications | notifications_ | notifications_user_notifications |
Webhooks | webhooks_ | webhooks_subscriptions |
BlobStorage | blob_storage_ | blob_storage_descriptors |
Features | features_ | features_overrides |
Localization | localization_ | localization_overrides |
AI | ai_ | ai_workspaces |
ApiKeys | api_keys_ | api_keys_api_keys |
Authorization | authorization_ | authorization_permission_grants |
OpenIddict | openiddict_ | openiddict_users |
Identity | identity_ | identity_user_cache_entries |
DataExchange | data_exchange_ | data_exchange_import_jobs |
QueryEngine | query_engine_ | query_engine_saved_views |
Templating | templating_ | templating_revisions |
Timeline | timeline_ | timeline_entries |
Generating and applying migrations
Section titled “Generating and applying migrations”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.
For zero-downtime K8s deployments, generate an idempotent SQL script and apply it before the new pods start. This decouples schema changes from application startup — critical when running multiple replicas:
# Generate idempotent SQL scriptdotnet ef migrations script --idempotent -o migrate.sql \ --project src/MyApp.Host \ --context AppDbContext
# Apply via CI/CD pipeline (e.g., Flyway, psql, sqlcmd)psql -h $DB_HOST -U $DB_USER -d $DB_NAME -f migrate.sqlCombine with Expand & Contract for column renames, type changes, and other breaking schema changes that require data backfill.
dotnet ef migrations add AddBackgroundJobsTables \ --project src/MyApp.Host \ --context AppDbContextData seeding
Section titled “Data seeding”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.
Soft delete purging
Section titled “Soft delete purging”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.
Multi-tenancy strategies
Section titled “Multi-tenancy strategies”Granit.Persistence supports three tenant isolation topologies:
| Strategy | Isolation | Complexity | Use case |
|---|---|---|---|
SharedDatabase | Query filter (TenantId column) | Low | Most applications |
SchemaPerTenant | PostgreSQL schema per tenant | Medium | Stronger isolation, shared infra |
DatabasePerTenant | Separate database per tenant | High | Maximum isolation, regulated data |
Configuration:
{ "TenantIsolation": { "Strategy": "SharedDatabase" }, "MultiTenancy:TenantSchema": { "NamingConvention": "TenantId", "Prefix": "tenant_" }}Built-in schema activators: PostgresqlTenantSchemaActivator, MySqlTenantSchemaActivator,
OracleTenantSchemaActivator.
Cross-package model extensions
Section titled “Cross-package model extensions”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);}How it applies
Section titled “How it applies”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 builtservices.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.
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Module | GranitPersistenceModule | Granit.Persistence |
| Interceptors | AuditedEntityInterceptor, VersioningInterceptor, ConcurrencyStampInterceptor, DomainEventDispatcherInterceptor, SoftDeleteInterceptor | Granit.Persistence |
| Extensions | AddGranitPersistence(), AddGranitDbContext<T>(), UseGranitInterceptors(), ApplyGranitConventions() | Granit.Persistence |
| Data seeding | IHostDataSeedContributor, ITenantDataSeedContributor, DataSeedContext (PR #942 split the legacy IDataSeedContributor) | Granit.Persistence |
| Purging | ISoftDeletePurgeTarget, PurgeSoftDeletedBeforeAsync() | Granit.Persistence |
| Query extensions | IncludeTranslations(), WhereTranslation(), OrderByTranslation() | Granit.Persistence |
| Multi-tenancy | TenantIsolationStrategy, ITenantSchemaProvider, ITenantConnectionStringProvider, ITenantSchemaActivator, IsolatedDbContextMarker | Granit.Persistence |
| Tenant provisioning | ITenantProvisioner, AutoTenantProvisioner | Granit.Persistence.EntityFrameworkCore.Hosting |
| Host migrations | Configure{Module}Module(), Granit{Module}DbProperties | Each *.EntityFrameworkCore package |
| Migrations | MigrationPhase, MigrationCycleAttribute, IMigrationCycleRegistry, BatchMigrationDelegate | Granit.Persistence.EntityFrameworkCore.Migrations |
| Wolverine dispatch | GranitPersistenceMigrationsWolverineModule | Granit.Persistence.Migrations.Wolverine |
| Model extensions | IGranitModelExtension, GranitModelCacheKeyFactory | Granit.Persistence.EntityFrameworkCore |
Testing persistence
Section titled “Testing persistence”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).
See also
Section titled “See also”- Interceptors — audit, soft delete, concurrency, domain events, versioning
- Query filters — named filters, runtime bypass, translations
- Address platform — the spatial PostGIS opt-in that
IGranitModelExtensionpowers - Zero-downtime migrations — Expand & Contract pattern
- Adding Persistence guide — step-by-step tutorial
- Core module — domain base types, filter interfaces,
IDataFilter - Multi-tenancy concept — isolation strategies in depth
- Wolverine module — domain event dispatch and durable messaging
- Blog: 5 EF Core mistakes that kill performance — common pitfalls and how Granit’s persistence stack avoids them
- Blog: Isolated DbContext per module — why each module owns its own DbContext and how it scales
- Blog: Never return EF Core entities — projection patterns that keep your API contract stable