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:
- Queries the tenant catalog for all
Activetenants - For each tenant, resolves the connection string with the tenant's
search_path - Applies pending EF Core migrations against that tenant's schema
- 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:
- Queries the tenant catalog for all
Activetenants - For each tenant, resolves the dedicated database connection string
- Applies pending EF Core migrations to that tenant's database
- Failed tenants are logged but don't block others
Provisioning New Tenants¶
When a new tenant is provisioned via PgTenantProvisioner.ProvisionAsync():
- CREATE DATABASE — new PostgreSQL database named
tenant_{guid} - Catalog insert — entry with status
Provisioningand the connection string - Run migrations — full EF Core migration history applied immediately
- 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.