A Database-per-Tenant multi-tenancy building block for .NET 10: dynamic per-tenant connection resolution, JWT authentication with tenant-claim cross-checking, a three-layer tenant metadata cache, plan-based feature flags/quotas, per-tenant resilience (timeout + circuit breaker), and a migration tool with per-tenant failure isolation.
See docs/SDD.md for the full design rationale.
src/BuildingBlocks/BuildingBlocks.MultiTenancy/ Reusable multi-tenancy plumbing (no DB dependency)
src/Services/Catalog.Admin/ Central catalog: Plan/Tenant/PlanFeature/PlanQuota + EF store
src/Services/SaaS.CoreEngine.Api/ The tenant-facing API (billing domain demo: Customer/Product/Invoice)
src/Tools/Tools.DbMigrator/ Sweeps active tenants and applies pending migrations
tests/BuildingBlocks.MultiTenancy.UnitTests/ Pure unit tests, no Docker required
tests/SaaS.CoreEngine.IntegrationTests/ Testcontainers-backed integration tests (require Docker)
docker/postgres-tenants/init/ First-boot script creating the demo tenant databases
docker-compose.yml Local dev stack: postgres-central, postgres-tenants, redis, api
- .NET SDK 10.0+
- Docker Desktop (for
docker composeand for the Testcontainers-backed integration tests) dotnet-efglobal tool (dotnet tool install --global dotnet-ef), matching the EF Core version pinned inDirectory.Packages.props
This is the exact sequence that was run and verified end-to-end for this
repository. It runs the API and Tools.DbMigrator on the host via
dotnet run, with Postgres and Redis provided by docker-compose — not
the containerized api service (see "Known limitation" below).
# 1. Copy the environment template (edit if you need non-default ports/credentials)
Copy-Item .env.example .env
# 2. Start Postgres (catalog + tenants cluster) and Redis
docker compose up -d postgres-central postgres-tenants redis
# 3. Wait for all three to report healthy
docker compose ps
# 4. Apply the catalog migration
$env:DOTNET_CATALOG_CONNECTION = "Host=localhost;Port=5433;Database=saas_catalog;Username=postgres;Password=postgres"
dotnet ef database update --project src/Services/Catalog.Admin/Catalog.Admin.csproj --context CatalogDbContext
# 5. Run the API once - in Development, it automatically seeds two demo
# tenants (acme -> Starter plan, globex -> Pro plan) on startup.
dotnet run --project src/Services/SaaS.CoreEngine.Api/SaaS.CoreEngine.Api.csproj
# (Ctrl+C once you see "Application started" - the seed has already run)
# 6. Apply the billing schema to both tenant databases
$env:ConnectionStrings__Catalog = "Host=localhost;Port=5433;Database=saas_catalog;Username=postgres;Password=postgres"
dotnet run --project src/Tools/Tools.DbMigrator/Tools.DbMigrator.csproj
# Expected output: "acme: Applied (...)" and "globex: Applied (...)"
# 7. Run the API again and try it
dotnet run --project src/Services/SaaS.CoreEngine.Api/SaaS.CoreEngine.Api.csprojGet a dev JWT and call the API (Development-only /api/dev/token endpoint):
$token = (Invoke-RestMethod -Method Post -Uri http://localhost:5299/api/dev/token `
-ContentType "application/json" -Body '{"tenantKey":"acme"}').accessToken
Invoke-RestMethod -Uri http://localhost:5299/api/_diagnostics/whoami `
-Headers @{ Authorization = "Bearer $token"; "X-Tenant-Id" = "acme" }
Invoke-RestMethod -Uri http://localhost:5299/api/_diagnostics/db `
-Headers @{ Authorization = "Bearer $token"; "X-Tenant-Id" = "acme" }
# currentDatabase should be "tenant_acme". Repeat with tenantKey "globex" and
# X-Tenant-Id "globex" - currentDatabase will be "tenant_globex".By default, dotnet run on the host does not set Redis:ConnectionString,
so ITenantStore resolves directly against the catalog (correct, just
uncached). To exercise the three-layer cache locally against the Redis
container started above:
$env:Redis__ConnectionString = "localhost:6379"
dotnet run --project src/Services/SaaS.CoreEngine.Api/SaaS.CoreEngine.Api.csproj# Unit tests only (no Docker required)
dotnet test tests/BuildingBlocks.MultiTenancy.UnitTests
# Full suite, including Testcontainers-backed integration tests (Docker required)
dotnet test SaaS.CoreEngine.slnxAll tests are expected green with Docker running: 11/11 unit tests, 39/39 integration tests, 0 skipped.
There is no self-service tenant provisioning UI - this is a deliberate, manual, auditable operation:
- Create the physical database on the tenants cluster:
CREATE DATABASE tenant_<key>; \c tenant_<key> CREATE EXTENSION IF NOT EXISTS citext;
- Insert a row into the catalog's
tenantstable (viaCatalog.Admin'sEfCoreTenantStore/CatalogDbContext, or a small admin script) with the new tenant'sKey,Name,PlanId,DbHost,DbPort,DbName, andCredentialRef(must match an entry inTenantCredentials:Credentialsin configuration - never store a raw password on the tenant row). - Run
Tools.DbMigrator --tenant <key>to apply the billing schema to the new tenant database:dotnet run --project src/Tools/Tools.DbMigrator/Tools.DbMigrator.csproj -- --tenant <key>
- Confirm with a dry run first if you want to preview pending migrations
without applying anything:
Tools.DbMigrator --dry-run --tenant <key>.
- Containerized
apiservice vs. demo tenant seed data:CatalogSeederhardcodes the two demo tenants'DbHostas"localhost", which is correct when the API runs viadotnet runon the host talking to compose's published ports (5433/5434). It is not correct for theapiservice running inside compose talking topostgres-tenantsby Docker service name. This was verified directly:docker compose up -d apistarts cleanly and/health/readyreports healthy (the catalog, reached via thepostgres-centralservice name, is fine), but a tenant-scoped request (e.g.GET /api/_diagnostics/db) returns503 Server Unavailable- the correct, honest response fromTenantUnavailableExceptionHandlerwhen the seeded tenant row'sDbHost="localhost"cannot be reached from inside the container. The verified, working flow in this README (steps 1-7) runs Postgres/Redis in Docker and the API/migrator on the host. Running theapicontainer itself against the demo tenants requires either re-seeding withDbHost="postgres-tenants"or a per-environment host override, neither of which is implemented yet. /health/tenantscircuit state: implemented and manually reviewed, but no automated test exercises an actually-open circuit (would require at least 4 failures per the configuredMinimumThroughput).Tools.DbMigratorpartial-batch failures: seedocs/SDD.mdsection 10- EF Core 10 does not wrap a tenant's full migration batch in one
transaction, so a
Failedoutcome for a tenant means "inspect its actual schema state", not "nothing was applied".
- EF Core 10 does not wrap a tenant's full migration batch in one
transaction, so a