Skip to content

Provisioning

Tenant provisioning is how you create the infrastructure for a new tenant at runtime. What that means — and what your code must do — depends on the isolation strategy.

TenantIsolation.None — No Provisioning

When TenantIsolation = None, there are no tenants. No provisioner, catalog, connection resolver, or migration runner is generated. If you've marked entities [TenantScoped], EF Core query filters are still emitted, but they read from CorrelationContext.Current?.TenantId at query time — not provisioned at any point.

Provisioning step: none.


TenantIsolation.SchemaPerTenant

All tenants share a single Postgres database; each tenant gets its own PostgreSQL schema (e.g., tnt_abc123). EF query filters add defense-in-depth on top of the schema isolation.

What gets auto-wired

// Reads tenant's schema name from catalog, appends search_path to connection string
services.TryAddScoped<ITenantConnectionResolver>(sp =>
    new SchemaPerTenantConnectionResolver(
        sp.GetRequiredService<ITenantStore>(),
        sp.GetRequiredService<IOptions<DeepstagingPostgresOptions>>().Value));

// Per bounded context (one set per [DataStore])
services.AddDbContextFactory<YourStoreDbContext>();
services.TryAddScoped<TenantDbContextFactory<YourStoreDbContext>>();
services.AddHostedService<TenantMigrationRunner<YourStoreDbContext>>();
services.TryAddScoped<ITenantProvisioner, PgTenantProvisioner<YourStoreDbContext>>();

// File store — wraps registered IFileStore to prefix keys with {tenantId}/
global::Deepstaging.Storage.TenantScopedFileStore.DecorateFileStore(services);

Provisioning a new tenant

Inject ITenantProvisioner and call it. PgTenantProvisioner does everything in a single call:

  1. Generates a unique tenant ID
  2. Creates the tenant schema in the shared database
  3. Inserts a catalog row with status = Provisioning and the schema name
  4. Runs EF Core migrations against the new schema (Npgsql's search_path isolates each tenant's __EFMigrationsHistory)
  5. Updates the catalog row to status = Active
[CommandHandler]
public async Task<TenantInfo> Handle(CreateTenantCommand cmd, ITenantProvisioner provisioner)
{
    var tenant = await provisioner.ProvisionAsync(cmd.Name, ct);
    // tenant.Status == Active
    // tenant.SchemaName == "tnt_abc123"
    return tenant;
}

That's the entire provisioning step for SchemaPerTenant. No storage provisioning is needed. TenantScopedFileStore handles file isolation transparently by prefixing every key with {tenantId}/ — the shared container just works.

Schema layout

myappdb (shared database)
├── ds_tenants/           # Tenant catalog (shared)
│   └── catalog
├── tnt_acme_catalog/     # Tenant "acme" — Catalog bounded context
├── tnt_acme_ordering/    # Tenant "acme" — Ordering bounded context
├── tnt_contoso_catalog/  # Tenant "contoso" — Catalog bounded context
└── tnt_contoso_ordering/ # Tenant "contoso" — Ordering bounded context

TenantIsolation.DatabasePerTenant

Each tenant gets a completely separate Postgres database. EF query filters on [TenantScoped] entities are omitted — physical isolation is sufficient.

What gets auto-wired

// Reads tenant's full connection string directly from the catalog
services.TryAddScoped<ITenantConnectionResolver>(sp =>
    new DatabasePerTenantConnectionResolver(sp.GetRequiredService<ITenantStore>()));

// Per bounded context (one set per [DataStore])
services.AddDbContextFactory<YourStoreDbContext>();
services.TryAddScoped<TenantDbContextFactory<YourStoreDbContext>>();
services.AddHostedService<TenantMigrationRunner<YourStoreDbContext>>();
services.TryAddScoped<ITenantProvisioner, PgTenantProvisioner<YourStoreDbContext>>();

// File store — routes each operation to a per-tenant Azure Blob container
services.AddSingleton<ITenantContainerResolver, BlobTenantContainerResolver>();
services.Replace(ServiceDescriptor.Singleton<IFileStore, TenantContainerFileStore>());
services.AddSingleton<BlobContainerProvisioner>();
services.AddHostedService<TenantContainerHealthCheck>();

Provisioning a new tenant

For DatabasePerTenant with Azure Blob Storage, you must call two provisioners. The framework does not compose them — your command handler orchestrates both:

[CommandHandler]
public async Task<TenantInfo> Handle(
    CreateTenantCommand cmd,
    ITenantProvisioner dbProvisioner,
    BlobContainerProvisioner blobProvisioner)
{
    // Step 1: Provision the database
    var tenant = await dbProvisioner.ProvisionAsync(cmd.Name, ct);
    // ✓ CREATE DATABASE "tenant_{guid}" executed against postgres admin database
    // ✓ Catalog row inserted (status: Provisioning)
    // ✓ Full EF Core migration history applied to the new database
    // ✓ Catalog row updated (status: Active)

    // Step 2: Provision the blob container
    await blobProvisioner.ProvisionAsync(tenant.Id.Value, ct);
    // ✓ Azure Blob container "tnt-{tenantId}" created
    // ✓ Idempotent — safe to retry if Step 2 fails and you retry

    return tenant;
}

BlobContainerProvisioner is separate from ITenantProvisioner because not all DatabasePerTenant apps use Azure Blob Storage, and the framework cannot generalize the ordering or error semantics between "create database" and "create storage" without constraining your transaction model.

Database layout

postgres (admin)
myappdb (catalog)
└── ds_tenants.catalog       # Tenant metadata + connection strings

tenant_abc123 (Acme Corp)
├── catalog/                 # Catalog bounded context
└── ordering/                # Ordering bounded context

tenant_def456 (Contoso)
├── catalog/
└── ordering/

Catalog Database Setup

In Dedicated mode, the catalog database holds the tenant registry (ds_tenants.catalog table) and any non-tenant-scoped entities. This database must have its schema applied before tenant provisioning can work.

Development

No action needed. DeepstagingSchemaService automatically applies pending migrations on startup in development mode.

Production

Before the first deployment of a Dedicated-mode app:

# Apply migrations to the shared/catalog database
ef.sh update <context-name>

DeepstagingSchemaService validates the catalog schema on startup. If migrations are pending, it reports to StartupValidator and blocks the app from starting — ensuring you cannot provision tenants against an unmigrated catalog.

Subsequent deployments: the same ef.sh update command applies any new migrations. Tenant databases are migrated automatically by TenantMigrationRunner on startup.


Entity Routing in Dedicated Mode

Deepstaging routes database operations based on the [TenantScoped] attribute:

Entity Attribute Database
Tenant entities [StoredEntity] + [TenantScoped] Tenant database
Catalog entities [StoredEntity] only Catalog (shared) database

Catalog entities — like Account, Subscription, or FeatureFlag — are shared across all tenants. Their stores always connect to the catalog database, regardless of the current tenant context.

This means you can safely call PlatformStore.Accounts.Require(id) from within a PostProvisionHook — it reads from the catalog database, not the tenant database being provisioned.

How it works:

  • Tenant-scoped stores resolve their connection through TenantDbContextFactory<T>, which reads CorrelationContext.TenantId and calls ITenantConnectionResolver.
  • Catalog stores use the standard DbContextOptions<T> registered in DI, which always points to the catalog connection string from configuration.
  • The generator handles this automatically — no consumer configuration needed.

How Provisioning Interacts with Migrations

At provisioning time

PgTenantProvisioner runs EF Core migrations as part of the provisioning call itself — step 4 in the flow above. A newly provisioned tenant is immediately at the current schema version and can accept requests right away.

At startup

TenantMigrationRunner<TContext> runs as a hosted service on every startup. It queries the catalog for all Active tenants and applies any pending migrations to each one. This is how schema changes introduced by new deployments reach existing tenants:

  1. You add a migration (dotnet ef migrations add ...)
  2. You deploy
  3. On startup, TenantMigrationRunner applies the pending migration to every active tenant's schema (or database)
  4. Migrations run in parallel up to TenantMigrationOptions.MaxParallelism (default: 4)

Development: Migration failures are logged but don't block other tenants or prevent startup.

Production: Pending or failed migrations are reported to StartupValidator — the application refuses to start until resolved.

Tip

MaxParallelism = 4 is conservative. With many tenants and a fast database host, raising it to 8–16 reduces startup time significantly.

The double-migration guarantee

Because PgTenantProvisioner runs migrations at provisioning time and TenantMigrationRunner runs them again at startup, both paths are always safe:

  • Newly provisioned tenant — migrated to current version during provisioning, startup runner finds nothing pending
  • Existing tenant after deployment — startup runner applies the new migration
  • Retry after failed provisioning — idempotent MigrateAsync() skips already-applied migrations

Blob Container Health Check (DatabasePerTenant only)

TenantContainerHealthCheck runs as a hosted service alongside TenantMigrationRunner. At startup it verifies that an Azure Blob container (tnt-{tenantId}) exists for every active tenant:

Environment Container missing Action
Development Auto-creates via CreateIfNotExistsAsync Startup continues
Production Throws InvalidOperationException Startup aborted

The production fail-fast behaviour prevents silent data-loss from mis-provisioned tenants. BlobContainerProvisioner.ProvisionAsync is idempotent, so you can safely include it in any retry or recovery script.


Seeding Extension Points

Two interfaces let you inject custom logic into the provisioning and startup lifecycles without modifying the provisioner itself.

IPostProvisionHook — Per-Tenant Seed After Migration (Dedicated only)

Implement IPostProvisionHook to run logic against a freshly migrated tenant database before the tenant is activated. The hook fires after EF Core migrations complete and before the catalog row is updated to Active.

public sealed class SeedDefaultRolesHook : IPostProvisionHook
{
    private readonly TenantDbContextFactory<AppStoreDbContext> _factory;

    public SeedDefaultRolesHook(TenantDbContextFactory<AppStoreDbContext> factory)
        => _factory = factory;

    public async Task ExecuteAsync(TenantInfo tenant, CancellationToken ct = default)
    {
        await using var db = await _factory.CreateForTenantAsync(tenant.Id, ct);
        db.Roles.AddRange(Role.Defaults());
        await db.SaveChangesAsync(ct);
    }
}

Register it using the generated typed helper in your AddDeepstaging callback:

builder.AddDeepstaging(ds =>
{
    ds.AddAppPostProvisionHook<SeedDefaultRolesHook>();
});

The generator emits Add{Runtime}PostProvisionHook<T>() for each runtime when using Dedicated isolation. Multiple hooks are executed in registration order.

Only available for TenantIsolation.Dedicated. Shared mode uses TryAddScoped<ITenantProvisioner, PgTenantProvisioner<TContext>>() directly — post-provision hooks are not injected. If you need per-tenant seeding with Shared isolation, implement it in your command handler after calling ProvisionAsync.

Note

Hook failures propagate as exceptions from ProvisionAsync — the same behaviour as a migration failure. The catalog row remains in Provisioning status, which allows retry. Hooks should be idempotent.

IStartupSeeder — App-Startup System Seed

Implement IStartupSeeder to seed shared (non-tenant-scoped) data once at application startup. The DeepstagingStartupSeedRunner hosted service resolves all keyed seeders for the runtime and runs them sequentially before the application begins serving traffic.

public sealed class SeedPermissionsSeeder : IStartupSeeder
{
    private readonly AppStoreDbContext _db;

    public SeedPermissionsSeeder(AppStoreDbContext db) => _db = db;

    public async Task SeedAsync(CancellationToken ct = default)
    {
        if (!await _db.Permissions.AnyAsync(ct))
        {
            _db.Permissions.AddRange(Permission.All());
            await _db.SaveChangesAsync(ct);
        }
    }
}

Register it using the generated typed helper in your AddDeepstaging callback:

builder.AddDeepstaging(ds =>
{
    ds.AddAppStartupSeeder<SeedPermissionsSeeder>();
});

The generator emits Add{Runtime}StartupSeeder<T>() for every runtime. DeepstagingStartupSeedRunner is registered as a hosted service for every Shared and Dedicated runtime.

Execution Order

For a Dedicated runtime, the full provisioning + seeding sequence is:

App startup
  └── DeepstagingStartupSeedRunner (IStartupSeeder keyed "App")
        ├── SeedPermissionsSeeder.SeedAsync()   ← shared system data
        └── ...

ProvisionAsync("Acme Corp")
  ├── Create database
  ├── Insert catalog row (status: Provisioning)
  ├── Run EF Core migrations
  ├── IPostProvisionHook keyed "App"
  │     └── SeedDefaultRolesHook.ExecuteAsync() ← tenant-specific seed
  └── Update catalog row (status: Active)

Deprovisioning

ITenantProvisioner.DeprovisionAsync() sets the tenant status to Closed. It does not drop the database or remove the blob container — data is preserved for your retention/export window.

await provisioner.DeprovisionAsync(tenantId);
// Tenant status: Active → Closed
// Database / schema: preserved
// Blob container: preserved
// TenantMigrationRunner: skips Closed tenants
// TenantContainerHealthCheck: skips Closed tenants

Summary

None SchemaPerTenant DatabasePerTenant
Provisioning call ITenantProvisioner.ProvisionAsync() ITenantProvisioner.ProvisionAsync() + BlobContainerProvisioner.ProvisionAsync()
Migrations at provision Yes, via PgTenantProvisioner Yes, via PgTenantProvisioner
Migrations at startup TenantMigrationRunner TenantMigrationRunner
Storage check at startup TenantContainerHealthCheck (dev: auto-create, prod: fail-fast)
File key isolation {tenantId}/ prefix in shared container Separate container tnt-{tenantId}
EF query filters Yes Yes No