Isolation Strategies — Data Separation
Granit supports three data isolation strategies for multi-tenant applications. You can choose a single strategy for all tenants, or mix strategies dynamically — premium tenants get dedicated databases while standard tenants share a single database.
Strategy comparison
Section titled “Strategy comparison”| Strategy | Isolation | Tenant limit | Backup | Operational cost |
|---|---|---|---|---|
SharedDatabase | Logical (WHERE clause) | Unlimited | Single backup | Lowest |
SchemaPerTenant | Schema-level | ~1,000/database | Per-schema pg_dump -n | Medium |
DatabasePerTenant | Physical (separate DB) | ~200 (with PgBouncer) | Per-tenant | Highest |
Decision matrix
Section titled “Decision matrix”flowchart TD
A[New tenant onboarding] --> B{"ISO 27001 physical<br/>separation required?"}
B -- Yes --> C[DatabasePerTenant]
B -- No --> D{"More than 1,000<br/>tenants expected?"}
D -- Yes --> E[SharedDatabase]
D -- No --> F[SchemaPerTenant]
Configuration
Section titled “Configuration”All tenants share one database. Isolation is enforced through EF Core query filters
on IMultiTenant entities. Default strategy — no additional configuration needed.
{ "TenantIsolation": { "Strategy": "SharedDatabase" }}builder.Services.AddGranitIsolatedDbContext<AppDbContext>( configureShared: options => options.UseNpgsql(connectionString));Each tenant gets a dedicated PostgreSQL schema. The TenantSchemaConnectionInterceptor
executes SET search_path unconditionally on every connection open.
{ "TenantIsolation": { "Strategy": "SchemaPerTenant", "HostSchema": "host" }}builder.Services.AddGranitIsolatedDbContext<AppDbContext>( configureShared: options => options.UseNpgsql(connectionString), configureSchemaPerTenant: options => options.UseNpgsql(connectionString), configureTenantSchema: schema => { schema.NamingConvention = TenantSchemaNamingConvention.TenantId; schema.Prefix = "tenant_"; });Maximum isolation — each tenant has its own database. Connection strings are resolved
dynamically via ITenantConnectionStringProvider.
{ "TenantIsolation": { "Strategy": "DatabasePerTenant" }}builder.Services.AddSingleton<ITenantConnectionStringProvider, VaultConnectionStringProvider>();
builder.Services.AddGranitIsolatedDbContext<AppDbContext>( configureShared: options => options.UseNpgsql(connectionString), configureDatabasePerTenant: (options, cs) => options.UseNpgsql(cs));SchemaEnsurer
Section titled “SchemaEnsurer”EF Core / Npgsql never creates custom schemas automatically. If a module configures
a schema (e.g., "host") and migrations run before the schema exists, the provider
throws a fatal error.
Call SchemaEnsurer.EnsureSchemasAsync() at application startup, before any migration:
// In Program.cs, before migrationsawait SchemaEnsurer.EnsureSchemasAsync(context, cancellationToken, "host");For tenant provisioning, the AutoTenantProvisioner handles schema creation and
migration order automatically. If you need manual control:
// Manual provisioning (rare — prefer AutoTenantProvisioner)await SchemaEnsurer.EnsureSchemasAsync(context, ct, $"tenant_{tenantId:N}");await context.Database.MigrateAsync(ct); // search_path already activatedAutomatic tenant provisioning
Section titled “Automatic tenant provisioning”When a new tenant is created at runtime (via the admin API), TenantCreatedEvent is
dispatched as a domain event. The Granit.MultiTenancy.Provisioning package provides a
framework-level handler that automatically provisions the tenant — no application
code needed:
- Schema creation — calls
SchemaEnsurer.EnsureSchemasAsync()for SchemaPerTenant - EF Core migrations — runs
MigrateAsync()on every isolated DbContext discovered viaIsolatedDbContextMarker - Data seeding — executes all
ITenantDataSeedContributorimplementations for the new tenant
sequenceDiagram
participant API as Admin API
participant EF as EF Core SaveChanges
participant WV as Wolverine
participant TP as AutoTenantProvisioner
API->>EF: CreateAsync(tenant)
EF-->>WV: TenantCreatedEvent (domain event)
WV->>TP: HandleAsync → ProvisionAsync
TP->>TP: Discover IsolatedDbContextMarker(s)
TP->>TP: CREATE SCHEMA IF NOT EXISTS
TP->>TP: MigrateAsync per DbContext
TP->>TP: SeedTenantAsync (contributors)
Zero-configuration setup
Section titled “Zero-configuration setup”Install the package and add the module dependency:
dotnet add package Granit.MultiTenancy.Provisioning[DependsOn(typeof(GranitMultiTenancyProvisioningModule))]public sealed class AppHostModule : GranitModule { }The handler is auto-discovered by Wolverine. All DbContext types registered via
AddGranitIsolatedDbContext<T>() are provisioned automatically — no manual listing needed.
Custom provisioning
Section titled “Custom provisioning”Replace the default ITenantProvisioner to add application-specific logic (e.g.,
external registry calls, per-tenant configuration seeding):
// Register before AddGranitMigrateSupport() — TryAddSingleton won't overridebuilder.Services.AddSingleton<ITenantProvisioner, MyCustomTenantProvisioner>();Cold-start vs runtime
Section titled “Cold-start vs runtime”| Path | When | Who provisions |
|---|---|---|
--migrate | First deploy / schema changes | GranitMigrationRunner (re-migration pass after host seeding) |
| Runtime | Admin creates tenant via API | TenantProvisioningHandler → AutoTenantProvisioner |
Both paths are idempotent: CREATE SCHEMA IF NOT EXISTS, EF Core migration tracking,
and seed contributors that guard against duplicate inserts. Wolverine’s retry policies
can safely replay provisioning messages.
Hybrid model
Section titled “Hybrid model”The IsolatedDbContextFactory<TContext> resolves the strategy per tenant at
runtime via ITenantIsolationStrategyProvider. Premium tenants can use
DatabasePerTenant while standard tenants share a SharedDatabase — all in the
same deployment.
public sealed class TieredIsolationProvider( ITenantReader tenantReader) : ITenantIsolationStrategyProvider{ public async ValueTask<TenantIsolationStrategy> GetStrategyAsync( Guid? tenantId, CancellationToken ct = default) { if (tenantId is null) return TenantIsolationStrategy.SharedDatabase;
TenantData? tenant = await tenantReader .FindByIdAsync(tenantId.Value, ct) .ConfigureAwait(false);
return tenant?.IsPremium == true ? TenantIsolationStrategy.DatabasePerTenant : TenantIsolationStrategy.SharedDatabase; }}Wolverine context propagation
Section titled “Wolverine context propagation”When Wolverine dispatches messages, the OutgoingContextMiddleware serializes
ICurrentTenant.Id into an X-Tenant-Id message header. The TenantContextBehavior
restores it on the receiving side:
HTTP Request (Tenant A) --> Wolverine handler --> background job X-Tenant-Id: A -------------------> ICurrentTenant.Id = AThis ensures tenant isolation across async message processing without manual propagation.
See also
Section titled “See also”- Host vs Tenant — schema separation between host and tenant data
- Cache Isolation — tenant-scoped cache keys
- Persistence —
ApplyGranitConventions, interceptors, query filters