Skip to content

Migrations

EF Core migrations work differently depending on whether multi-tenancy is active and which isolation strategy is used.

Without Tenancy (TenantIsolation.None)

Standard single-database EF Core migrations. Each bounded context has its own DbContext and migration history.

Creating Migrations

# Per bounded context
dotnet ef migrations add InitialCreate \
    --project src/MyApp.Monolith \
    --context CatalogStoreDbContext \
    --output-dir Migrations/Catalog

dotnet ef migrations add InitialCreate \
    --project src/MyApp.Monolith \
    --context OrderingStoreDbContext \
    --output-dir Migrations/Ordering

Applying Migrations

Development: Pending migrations are applied automatically on startup. No action needed.

Production: Pending migrations cause a fail-fast startup error. Apply before deploying:

dotnet ef migrations script --idempotent \
    --context CatalogStoreDbContext \
    -o migrate-catalog.sql

psql -h $DB_HOST -d $DB_NAME -f migrate-catalog.sql

Multi-Context Wrapper Script

The deepstaging sync CLI generates an ef.sh wrapper script that handles multi-context migrations automatically. Run deepstaging sync (or deepstaging watch during development) to keep it up to date.

Usage: ./scripts/ef.sh add InitialCreate Catalog

With Tenancy — Shared

All tenants share one database. Each tenant gets its own PostgreSQL schema. The shared catalog database stores tenant metadata.

Creating Migrations

Same as without tenancy — migrations are defined against the bounded context's DbContext:

dotnet ef migrations add AddOrderStatus \
    --project src/MyApp.Monolith \
    --context OrderingStoreDbContext \
    --output-dir Migrations/Ordering

How Migrations Are Applied

TenantMigrationRunner<TContext> runs as a hosted service on startup:

  1. Queries the tenant catalog for all Active tenants
  2. For each tenant, resolves the connection string with the tenant's search_path
  3. Applies pending EF Core migrations against that tenant's schema
  4. Configurable parallelism via TenantMigrationOptions.MaxParallelism (default: 4)

Development: Migrations auto-apply to all tenant schemas. Failed tenants are logged but don't block others.

Production: Pending migrations are reported to StartupValidator — the app won't start until resolved.

Schema Layout

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

With Tenancy — Dedicated

Each tenant gets its own PostgreSQL database. The shared catalog database tracks tenant metadata and connection strings.

Creating Migrations

Same as above — migrations are defined once and applied to every tenant database:

dotnet ef migrations add AddOrderStatus \
    --project src/MyApp.Monolith \
    --context OrderingStoreDbContext \
    --output-dir Migrations/Ordering

How Migrations Are Applied

TenantMigrationRunner<TContext> works identically to Shared:

  1. Queries the tenant catalog for all Active tenants
  2. For each tenant, resolves the dedicated database connection string
  3. Applies pending EF Core migrations to that tenant's database
  4. Failed tenants are logged but don't block others

Provisioning New Tenants

When a new tenant is provisioned via PgTenantProvisioner.ProvisionAsync():

  1. CREATE DATABASE — new PostgreSQL database named tenant_{guid}
  2. Catalog insert — entry with status Provisioning and the connection string
  3. Run migrations — full EF Core migration history applied immediately
  4. Activate — status updated to Active

The connection string is derived from the admin connection via NpgsqlConnectionStringBuilder. Migrations run at provisioning time so the new tenant is immediately ready. The startup TenantMigrationRunner will find nothing pending for it.

Database Layout

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

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

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

Query Filter Behavior by Strategy

[TenantScoped] entities always require a TenantId property (enforced by analyzer DSTE01). The generated EF Core query filter depends on strategy:

Strategy HasQueryFilter Rationale
None Sole isolation mechanism
Shared Defense-in-depth alongside schema isolation
Dedicated Physical isolation makes the filter redundant

The [TenantScoped] attribute is still meaningful with Dedicated:

  • Analyzer DSTE01 — always enforced
  • In-memory store filtering — test doubles always filter by tenant
  • EF Core query filter — omitted; each tenant's database only contains that tenant's data

Migration Options

TenantMigrationOptions controls the startup migration runner:

Property Default Description
MaxParallelism 4 Maximum concurrent tenant migrations at startup
// Override in AddDeepstaging (rarely needed)
builder.AddDeepstaging(ds =>
{
    ds.AddAspirePostgres("myappdb");
    ds.Services.AddSingleton(new TenantMigrationOptions { MaxParallelism = 8 });
});

MaxParallelism = 4 is conservative. With many tenants and a fast database host, raising it reduces startup time proportionally.

Deprovisioning

ITenantProvisioner.DeprovisionAsync() sets the tenant status to Closed — it does not drop the database. Data is preserved for your retention/export window.

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