Skip to content
fullstackhero

Reference

Multitenancy module

Finbuckle-driven tenant resolution (claim, header, query), distributed-cache store, per-tenant connection strings, theme customisation, and an async provisioning state machine.

views 0 Last updated

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 (tenant is 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 TenantDbContext with AppTenantInfo as the record.
  • Per-tenant connection strings — each AppTenantInfo can carry its own DB connection, set on creation.
  • IGlobalEntity opt-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 tenant header 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, DTOs

How 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

TypePurpose
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

TypePurpose
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

TypePurpose
GetTenantThemeQuery(tenantId)Read
UpdateTenantThemeCommand(tenantId, ...)Update palette / brand / typography
ResetTenantThemeCommand(tenantId)Restore defaults (root’s theme, if set)

Endpoints

VerbRouteWhat it does
POST/api/v1/tenantsCreate tenant + provision
GET/api/v1/tenantsList tenants
GET/api/v1/tenants/{id}/statusGet status
PATCH/api/v1/tenants/{id}/activationActivate / deactivate
GET/api/v1/tenants/{id}/provisioning/statusStep-by-step provisioning state
POST/api/v1/tenants/{id}/provisioning/retryResume failed provisioning
GET/api/v1/tenants/{id}/themeGet theme
PUT/api/v1/tenants/{id}/themeUpdate theme
DELETE/api/v1/tenants/{id}/themeReset 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:

appsettings.json
{
"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 pattern
var 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.