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, drives the tenant subscription lifecycle (create with a plan, renew, expire with a grace window), and exposes a per-tenant theme customisation surface. Around 3,200 lines of code across 58 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 (Database → Migrations → Seeding → CacheWarm). Steps are persisted; failures are resumable via RetryTenantProvisioning.
  • Tenant lifecycle with plansCreateTenantCommand takes an optional PlanKey (falls back to Billing:DefaultPlanKey) and publishes TenantSubscribedIntegrationEvent; RenewTenant extends ValidUpto by the plan term and publishes TenantRenewedIntegrationEvent — the Billing module reacts to both with subscriptions + invoices. AdjustTenantValidity is the operator override that may backdate (the internal SetValidity used by renewal is forward-only and throws on backdating).
  • Active/expiry enforcement as post-auth guards — Finbuckle happily resolves inactive or expired tenants; two middleware guards (registered in IModule.ConfigureMiddleware) reject requests for deactivated tenants and enforce ValidUpto with a configurable grace window (Billing:GraceWindowDays, default 7). Inside the window, responses carry an X-Subscription-Grace header with days left.
  • Daily expiry scan — the tenant-expiry-scan Hangfire job (02:00 UTC) publishes nearing-expiry / entered-grace / expired events, deduped per tenant per validity window via TenantExpiryNotice.
  • 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/ ~3,200 LoC, 58 files
│ ├── MultitenancyModule.cs IModule entry — order 200
│ ├── TenantBillingOptions.cs "Billing" config section binding
│ ├── Domain/
│ │ ├── TenantTheme.cs Per-tenant theme row
│ │ └── TenantExpiryNotice.cs Dedupe record for expiry notifications
│ ├── Data/
│ │ └── TenantDbContext.cs EFCoreStoreDbContext<AppTenantInfo>
│ ├── Features/v1/ 13 features
│ │ ├── CreateTenant/ Create + kick off provisioning
│ │ ├── GetTenants/, GetTenantStatus/, GetMyTenantStatus/
│ │ ├── ChangeTenantActivation/, RenewTenant/, AdjustTenantValidity/
│ │ ├── GetTenantTheme/, UpdateTenantTheme/, ResetTenantTheme/
│ │ └── TenantProvisioning/ (status + retry), GetTenantMigrations/
│ ├── Provisioning/
│ │ ├── TenantProvisioning.cs State machine + ordered step rows
│ │ ├── TenantProvisioningService.cs Orchestrates steps, idempotent retries
│ │ └── TenantProvisioningJob.cs Hangfire job
│ └── Services/ TenantService, TenantThemeService,
│ TenantExpiryScanJob, TenantInitialPasswordBuffer
└── Modules.Multitenancy.Contracts/ Commands, queries, DTOs, integration events

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(ClaimConstants.Tenant) // 1. claim — no-op pre-auth
.WithHeaderStrategy(MultitenancyConstants.Identifier) // 2. "tenant" header — primary
.WithDelegateStrategy(ResolveTenantFromQuery) // 3. ?tenant= query string fallback
.WithDistributedCacheStore(TimeSpan.FromMinutes(60))
.WithStore<EFCoreStore<TenantDbContext, AppTenantInfo>>(ServiceLifetime.Scoped);

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 multi-tenant context on the HttpContext.

Two details worth knowing about the cache layer: a tenant resolved from the EF store is written through to the distributed cache on resolution (OnTenantResolveCompleted), and every tenant write path (activation, renew, adjust-validity, theme) refreshes the cached entry immediately — otherwise flips would lag up to the 60-minute TTL.

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?, planKey?)Creates a tenant, fires async provisioning, publishes TenantSubscribedIntegrationEvent
GetTenantsQuery(paging, search)Admin list view
GetTenantStatusQuery(tenantId)Fetch a single tenant’s status
ChangeTenantActivationCommand(tenantId, isActive)Toggle active
RenewTenantCommand(tenantId, planKey?)Extend ValidUpto by the plan term (forward-only), publishes TenantRenewedIntegrationEvent
AdjustTenantValidityCommand(tenantId, validUpto)Operator override — may move the date backward (immediate expiry, corrections)

GetMyTenantStatus has no command type — the endpoint reads the resolved tenant context directly and returns the caller’s own status.

Provisioning

TypePurpose
GetTenantProvisioningStatusQuery(tenantId)Returns step-level state
RetryTenantProvisioningCommand(tenantId)Resume from the first failed step
GetTenantMigrationsQuery()Lists applied/pending EF migrations across tenant databases

Themes

Theme types are scoped to the caller’s tenant context (no tenant id parameter — operators target a tenant via the root header override):

TypePurpose
GetTenantThemeQuery()Read
UpdateTenantThemeCommand(theme)Update palette / brand / typography
ResetTenantThemeCommand()Restore defaults

Endpoints

All under /api/v1/tenants. Permissions are from MultitenancyPermissions.Tenants (Permissions.Tenants.*).

VerbRouteWhat it doesPermission
POST/Create tenant + provisionTenants.Create
GET/List tenantsTenants.View
GET/{id}/statusGet statusTenants.View
GET/me/statusCalling tenant’s own status (plan, validity, grace)authenticated
POST/{id}/activationActivate / deactivateTenants.Update
POST/{id}/renewRenew subscription by plan termTenants.UpgradeSubscription
POST/{id}/adjust-validityOperator validity overrideTenants.UpgradeSubscription
GET/{tenantId}/provisioningStep-by-step provisioning stateTenants.View
POST/{tenantId}/provisioning/retryResume failed provisioningTenants.Update
GET/migrationsMigration status across tenant DBsTenants.View
GET/themeGet current tenant’s themeTenants.ViewTheme
PUT/themeUpdate themeTenants.UpdateTheme
POST/theme/resetReset themeTenants.UpdateTheme

Provisioning state machine

CreateTenantCommand returns immediately. The handler writes the AppTenantInfo row, writes a TenantProvisioning record with four ordered steps — Database, Migrations, Seeding, CacheWarm — 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.

src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioning.cs
public sealed class TenantProvisioning
{
public string TenantId { get; private set; }
public string CorrelationId { get; private set; }
public TenantProvisioningStatus Status { get; private set; } // Pending | Running | Completed | Failed
public string? CurrentStep { get; private set; }
public string? Error { get; private set; }
public string? JobId { get; private set; }
public ICollection<TenantProvisioningStep> Steps { get; private set; }
}

Configuration

Resolution itself is wired in code through Finbuckle setup; what you configure in appsettings is the store plumbing and the subscription-lifecycle knobs:

appsettings.json
{
"DatabaseOptions": {
"Provider": "POSTGRESQL",
"ConnectionString": "Host=...;Database=fsh;Username=...;Password=..."
},
"CachingOptions": {
"Redis": "localhost:6379" // distributed cache for DistributedCacheStore
},
"Billing": { // bound to TenantBillingOptions
"DefaultPlanKey": "free", // plan used when CreateTenant has no PlanKey
"GraceWindowDays": 7, // days past ValidUpto before hard block
"ExpiryNotificationLeadDays": 7 // how early the daily scan starts warning
}
}

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() (drops every filter) versus IgnoreQueryFilters([QueryFilters.SoftDelete]) (drops only the named soft-delete filter) for soft-delete-aware cross-tenant queries.

Tests

  • Unit tests at src/Tests/Multitenancy.Tests/ (9 test files).
  • Integration tests at src/Tests/Integration.Tests/Tests/Multitenancy/ (17 files) cover tenant isolation (no leaks), provisioning status + failure/retry, header override, activation, renewal, validity adjustment, expiry enforcement + the expiry scan job, themes, and seed-data flows.