Skip to content

Persistence — EF Core 10, Isolated DbContexts & Host-Owned Migrations

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/ Base interceptors, conventions, data seeding
    • Granit.Persistence.Migrations Zero-downtime batch migrations (Expand & Contract)
    • Granit.Persistence.Migrations.Wolverine Durable outbox-backed migration dispatch
PackageRoleDepends on
Granit.PersistenceInterceptors, ApplyGranitConventions, data seedingGranit, Granit.Timing, Granit.Guids, Granit.Users
Granit.Persistence.MigrationsExpand & Contract migrations with batch processingGranit.Persistence, Granit.Timing
Granit.Persistence.Migrations.WolverineDurable migration dispatch via Wolverine outboxGranit.Persistence.Migrations, Granit.Wolverine
graph TD
    P[Granit.Persistence] --> C[Granit]
    P --> T[Granit.Timing]
    P --> G[Granit.Guids]
    P --> S[Granit.Users]
    PM[Granit.Persistence.Migrations] --> P
    PM --> T
    PMW[Granit.Persistence.Migrations.Wolverine] --> PM
    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.

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) : IDataSeedContributor
{
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<IDataSeedContributor, CountryDataSeedContributor>().

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"
},
"TenantSchema": {
"NamingConvention": "TenantId",
"Prefix": "tenant_"
}
}

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

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

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).