Skip to content

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.

StrategyIsolationTenant limitBackupOperational cost
SharedDatabaseLogical (WHERE clause)UnlimitedSingle backupLowest
SchemaPerTenantSchema-level~1,000/databasePer-schema pg_dump -nMedium
DatabasePerTenantPhysical (separate DB)~200 (with PgBouncer)Per-tenantHighest
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]

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

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 migrations
await 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 activated

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:

  1. Schema creation — calls SchemaEnsurer.EnsureSchemasAsync() for SchemaPerTenant
  2. EF Core migrations — runs MigrateAsync() on every isolated DbContext discovered via IsolatedDbContextMarker
  3. Data seeding — executes all ITenantDataSeedContributor implementations 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)

Install the package and add the module dependency:

Terminal window
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.

Replace the default ITenantProvisioner to add application-specific logic (e.g., external registry calls, per-tenant configuration seeding):

// Register before AddGranitMigrateSupport() — TryAddSingleton won't override
builder.Services.AddSingleton<ITenantProvisioner, MyCustomTenantProvisioner>();
PathWhenWho provisions
--migrateFirst deploy / schema changesGranitMigrationRunner (re-migration pass after host seeding)
RuntimeAdmin creates tenant via APITenantProvisioningHandlerAutoTenantProvisioner

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.

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;
}
}

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 = A

This ensures tenant isolation across async message processing without manual propagation.