The Multitenancy module makes multi-tenant the default behaviour of the entire kit. It wires Finbuckle.MultiTenant 10 with a three-tier resolution strategy (claim → header → query), caches resolved tenants in a distributed store, supports per-tenant connection strings, ships a resumable provisioning state machine, and exposes a per-tenant theme customisation surface. Around 2,200 lines of code across 49 files, plus a contracts assembly the rest of the kit references.
What ships in v10
- Three-tier tenant resolution — claim, header (
tenantis the primary), query string (?tenant=). Finbuckle resolves first match wins. - DistributedCacheStore in front of the EF Core store — a resolved tenant is cached for 60 minutes to keep request hot-paths off the DB.
- EFCoreStore as the source of truth — tenants live in
TenantDbContextwithAppTenantInfoas the record. - Per-tenant connection strings — each
AppTenantInfocan carry its own DB connection, set on creation. IGlobalEntityopt-out — entities that need to live across tenants (plans, impersonation grants, outbox messages) mark this interface and skip the tenant filter.- Root-operator header override — SuperAdmin can scope a single request to another tenant via the
tenantheader for admin operations like cross-tenant user search; runs as post-auth middleware (the Finbuckle strategies see anonymous principals). - Provisioning state machine — multi-step tenant creation (schema migration → seed → activate). Steps are persisted; failures are resumable via
RetryTenantProvisioning. - Tenant themes — full light + dark palettes, brand assets, typography, layout knobs. The frontend reads these to render per-tenant branding.
Architecture at a glance
src/Modules/Multitenancy/├── Modules.Multitenancy/ ~2,200 LoC, 49 files│ ├── MultitenancyModule.cs IModule entry — order 200│ ├── Domain/│ │ ├── TenantTheme.cs IHasTenant, IAuditableEntity│ │ ├── TenantProvisioning.cs State machine│ │ └── TenantProvisioningStep.cs Ordered step rows│ ├── Data/│ │ └── TenantDbContext.cs EFCoreStoreDbContext<AppTenantInfo>│ ├── Features/v1/ 11 features│ │ ├── CreateTenant/ Create + kick off provisioning│ │ ├── GetTenants/, GetTenantStatus/ Read│ │ ├── ChangeTenantActivation/ Activate / deactivate│ │ ├── GetTenantTheme/, UpdateTenantTheme/, ResetTenantTheme/│ │ └── GetTenantProvisioningStatus/, RetryTenantProvisioning/, GetTenantMigrations/│ ├── Provisioning/│ │ ├── TenantProvisioningService.cs Orchestrates steps, idempotent retries│ │ └── TenantProvisioningJob.cs Hangfire job│ └── Services/TenantService.cs, TenantThemeService.cs└── Modules.Multitenancy.Contracts/ ~300 LoC commands, queries, DTOsHow resolution actually works
The Finbuckle strategy chain runs on every request, before authentication. The kit configures it like this:
// MultitenancyModule.cs (simplified)builder.Services.AddMultiTenant<AppTenantInfo>() .WithClaimStrategy("tenant") // 1. claim — no-op pre-auth .WithHeaderStrategy("tenant") // 2. header — primary .WithDelegateStrategy(ResolveTenantFromQuery) // 3. query string fallback .WithDistributedCacheStore(TimeSpan.FromHours(1)) .WithEFCoreStore<TenantDbContext, AppTenantInfo>();The root-operator header override is what lets a SuperAdmin (whose JWT carries tenant=root) scope a single request to another tenant. It runs as post-auth middleware via IModule.ConfigureMiddleware, checks that the caller is in the root tenant, reads the tenant header on the request, looks up the target, and replaces the TenantContext on the HttpContext.
Public API
The Contracts assembly exposes the surface the host and admin app talk to.
Tenant lifecycle
| Type | Purpose |
|---|---|
CreateTenantCommand(id, name, connectionString?, adminEmail, adminPassword, issuer?) | Creates a tenant, fires async provisioning |
GetTenantsQuery(paging, search) | Admin list view |
GetTenantStatusQuery(tenantId) | Fetch a single tenant’s status |
ChangeTenantActivationCommand(tenantId, isActive) | Toggle active |
Provisioning
| Type | Purpose |
|---|---|
GetTenantProvisioningStatusQuery(tenantId) | Returns step-level state |
RetryTenantProvisioningCommand(tenantId) | Resume from the first failed step |
GetTenantMigrationsQuery(tenantId) | Lists EF migrations applied to this tenant’s DB |
Themes
| Type | Purpose |
|---|---|
GetTenantThemeQuery(tenantId) | Read |
UpdateTenantThemeCommand(tenantId, ...) | Update palette / brand / typography |
ResetTenantThemeCommand(tenantId) | Restore defaults (root’s theme, if set) |
Endpoints
| Verb | Route | What it does |
|---|---|---|
| POST | /api/v1/tenants | Create tenant + provision |
| GET | /api/v1/tenants | List tenants |
| GET | /api/v1/tenants/{id}/status | Get status |
| PATCH | /api/v1/tenants/{id}/activation | Activate / deactivate |
| GET | /api/v1/tenants/{id}/provisioning/status | Step-by-step provisioning state |
| POST | /api/v1/tenants/{id}/provisioning/retry | Resume failed provisioning |
| GET | /api/v1/tenants/{id}/theme | Get theme |
| PUT | /api/v1/tenants/{id}/theme | Update theme |
| DELETE | /api/v1/tenants/{id}/theme | Reset theme |
Provisioning state machine
CreateTenantCommand returns immediately. The handler writes the AppTenantInfo row, writes a TenantProvisioning aggregate with a list of steps (apply migrations, run module seeders, mark active), buffers the admin password in ITenantInitialPasswordBuffer (singleton), and queues a Hangfire TenantProvisioningJob.
The job picks up the work, walks each step idempotently, and updates the persisted state. If any step throws, the step is marked Failed, the overall state is Failed, and the buffered admin password is left in place. RetryTenantProvisioning resumes from the first failed step.
public sealed class TenantProvisioning : AggregateRoot, IGlobalEntity{ public TenantProvisioningStatus Status { get; private set; } public IReadOnlyList<TenantProvisioningStep> Steps { get; }
public void MarkStepCompleted(string stepName) { /* ... */ } public void MarkStepFailed(string stepName, string error) { /* ... */ } public IEnumerable<TenantProvisioningStep> NextStepsFrom(string? stepName) { /* ... */ }}Configuration
The module reads no IOptions<T> directly — its behaviour is wired through Finbuckle setup. Key things to set in appsettings:
{ "DatabaseOptions": { "Provider": "PostgreSQL", "ConnectionString": "Host=...;Database=fsh;Username=...;Password=..." }, "CachingOptions": { "Redis": "localhost:6379" // distributed cache for DistributedCacheStore }}The root tenant is seeded by the database initialiser. Per-tenant connection strings are set during CreateTenantCommand; if omitted, tenants share the main connection.
How to extend
Add a new resolution strategy
To support subdomain-based tenant resolution (acme.fullstackhero.net), register an additional Finbuckle strategy:
.WithHostStrategy("__tenant__")…and configure your DNS / load balancer to inject the tenant slug into the hostname pattern.
Add a tenant-aware service to your module
Implement IHasTenant on the entity (already done if it inherits BaseEntity), keep the DbContext extending BaseDbContext, and the global query filter handles isolation automatically. To opt out for a system-wide table, implement IGlobalEntity on the entity.
Cross-tenant queries from an admin context
Use IgnoreQueryFilters() deliberately, then re-filter explicitly to whatever scope you want:
// Pattern from the kit's audit query — verified safe patternvar audits = await db.AuditRecords .IgnoreQueryFilters() .Where(a => allowedTenantIds.Contains(a.TenantId.Value)) .ToListAsync(ct).ConfigureAwait(false);The multitenancy deep-dive covers when to use IgnoreQueryFilters versus IgnoreNamedQueryFilter("SoftDelete") for soft-delete-aware cross-tenant queries.
Tests
- Unit tests at
src/Tests/Multitenancy.Tests/(13 files). - Integration tests at
src/Tests/Integration.Tests/Tests/Multitenancy/cover tenant isolation (no leaks), provisioning status, header override, and seed-data flows.
Related
- Identity module — the per-tenant user store this module’s tenancy underlies.
- Architecture: multitenancy deep-dive —
IGlobalEntity, named filters, isolation tests. - Persistence building block —
BaseDbContext, the auto-applied global filter.