Multitenancy isn’t a module in fullstackhero. It’s a property of the platform — every EF Core entity is tenant-aware by default, every cache is tenant-scoped, every background job carries the tenant context that enqueued it, and every audit row records which tenant it belongs to. You opt out with IGlobalEntity, not in.
This page explains how that works mechanically, the design decisions behind it, and the rules for the rare cross-tenant query.
Three layers, one default
Tenancy is enforced at three layers, each protecting the next:
| Layer | Where | What enforces it |
|---|---|---|
| HTTP request | BuildingBlocks/Web | Finbuckle resolves the tenant from the JWT claim, tenant header, or ?tenant= query; MultiTenantMiddleware sets the context. |
| Data access | BuildingBlocks/Persistence | BaseDbContext applies an EF Core global query filter to every IHasTenant entity. |
| Background work | BuildingBlocks/Jobs | FshJobFilter captures the tenant at enqueue and restores it before the job runs. |
You don’t write WHERE tenant_id = @tenantId in your queries. The filter is on every entity, every time, unless you explicitly bypass it.
Layer 1 — Tenant resolution
Finbuckle.MultiTenant 10 resolves the tenant on every request. The kit configures the chain:
// MultitenancyModule.ConfigureServices (simplified)builder.Services.AddMultiTenant<AppTenantInfo>() .WithClaimStrategy("tenant") // 1. claim .WithHeaderStrategy("tenant") // 2. header — the real primary in normal flow .WithDelegateStrategy(ResolveFromQuery) // 3. ?tenant= fallback .WithDistributedCacheStore(TimeSpan.FromHours(1)) .WithEFCoreStore<TenantDbContext, AppTenantInfo>();The kit caches resolved tenants in DistributedCacheStore for 60 minutes (Valkey backplane) to keep hot-path requests off the DB. The EFCoreStore is the source of truth.
The root-operator override
A SuperAdmin (whose JWT carries tenant=root) can scope a single request to another tenant. This is needed for cross-tenant admin operations like searching users in tenant X before starting impersonation.
The kit implements this as post-auth middleware, not as a Finbuckle strategy — because the strategy chain runs before auth, it can’t tell whether the caller is actually the root user. The middleware lives in MultitenancyModule.ConfigureMiddleware; it runs after UseAuthentication, checks User.IsInRoot(), reads the tenant header, looks up the target, and replaces the TenantContext on the HttpContext.
Layer 2 — Data access
BaseDbContext (in BuildingBlocks/Persistence) is the EF Core base every module’s DbContext inherits. Its OnModelCreating does two things:
protected override void OnModelCreating(ModelBuilder modelBuilder){ base.OnModelCreating(modelBuilder); modelBuilder.ApplyTenantIsolationByDefault(); // mark IHasTenant entities IsMultiTenant() modelBuilder.AppendGlobalQueryFilter<ISoftDeletable>(e => !((ISoftDeletable)e).IsDeleted);}ApplyTenantIsolationByDefault() walks every entity that doesn’t implement IGlobalEntity and calls entityBuilder.IsMultiTenant() on it — which is Finbuckle’s hook to install the tenant filter. The result: a query like await db.Products.ToListAsync(ct) automatically becomes WHERE tenant_id = @currentTenantId.
SaveChangesAsync sets TenantNotSetMode = TenantNotSetMode.Overwrite so any entity entering Added state without an explicit TenantId inherits the current tenant. No entity.TenantId = current.GetTenant() lines in your handlers.
Two opt-outs
IGlobalEntity — for entities that need to live across tenants. The kit uses it for:
BillingPlan— the plan catalogue is platform-wideImpersonationGrant— operator impersonation spans tenantsOutboxMessage,InboxMessage— eventing infrastructure
If you mark an entity IGlobalEntity, the global tenant filter is not applied. Queries against the table return rows for every tenant; access control becomes the handler’s responsibility.
IgnoreNamedQueryFilter("SoftDelete") — the kit registers SoftDelete as a named filter, so individual queries can bypass it without bypassing the tenant filter:
var trashed = await db.Products .IgnoreNamedQueryFilter("SoftDelete") .Where(p => p.IsDeleted) .ToListAsync(ct).ConfigureAwait(false);This is the pattern for “list trashed items” queries. The tenant filter still applies; the soft-delete filter doesn’t.
Cross-tenant queries (rare, but real)
Auditing needs to query across tenants. Tenant-admin reports need cross-tenant aggregations. The pattern:
// Auditing module's GetAuditsQueryHandlervar audits = await db.AuditRecords .IgnoreQueryFilters() // turn off ALL filters .Where(a => allowedTenantIds.Contains(a.TenantId.Value)) .Where(a => !a.IsDeleted) // re-apply soft delete manually .ToListAsync(ct).ConfigureAwait(false);The pattern: use IgnoreQueryFilters() deliberately, then re-filter explicitly to whatever scope the caller is allowed to see. Don’t sprinkle this; reserve it for specific admin / audit query handlers and gate them with the strictest permissions.
Layer 3 — Background work
Hangfire jobs run on threads that have no HttpContext, so the tenant context that MultiTenantMiddleware set on the originating request is gone by the time the job dequeues. FshJobFilter (in BuildingBlocks/Jobs) handles this:
- At enqueue time — the filter captures
ICurrentUser.GetTenant()and stores it as a Hangfire job parameter. - At execute time — the filter reads the stored parameter and pushes it onto
IMultiTenantContextSetterbefore the job body runs. The job sees the same tenant the request did.
The same pattern works for integration-event handlers under WebhookFanoutHandler: the handler explicitly restores tenants.SetTenant(evt.TenantId.Value) before querying the subscription table. Forget this and the Finbuckle query filter returns no rows on the background thread.
Per-tenant connection strings
Each AppTenantInfo row carries an optional ConnectionString. When set, Finbuckle’s EFCoreStore overrides the DbContext’s connection at runtime. This lets a high-value tenant get its own database without changing application code.
When not set, all tenants share the kit’s main database with row-level isolation via the global query filter. For most teams, row-level isolation is sufficient through the kit’s lifecycle and scales further than you’d expect — Postgres handles hundreds of thousands of tenants in one database fine. Reach for per-tenant databases only when you have a compliance requirement (HIPAA, PCI-DSS) or a single tenant whose data volume genuinely needs its own physical store.
Caching tenant-scoped data
The Caching block (HybridCache) is tenant-aware by convention: include tenantId in every cache key. The kit’s pattern:
await cache.GetOrCreateAsync( $"product:{tenantId}:{productId}", async (ct) => await db.Products.FirstAsync(...), tags: [$"tenant:{tenantId}"], cancellationToken: ct).ConfigureAwait(false);The tag-based eviction lets you invalidate a tenant’s entire cache on plan change, suspension, or sign-out:
await cache.RemoveByTagAsync($"tenant:{tenantId}", ct).ConfigureAwait(false);L1 (in-memory) cache has no backplane; the kit’s DefaultLocalCacheExpiration (2 minutes) bounds the cross-node staleness window after a peer’s RemoveByTag.
The documented exception: Billing
One module deliberately steps outside the default. BillingDbContext is a plain
DbContext — not BaseDbContext — so the auto-applied tenant filter does
not govern it. Subscriptions, invoices, and usage snapshots instead carry an
explicit TenantId column, and the billing query services filter on it by hand.
This is intentional: billing is a finance/admin concern that routinely queries
across tenants — the monthly invoice job iterates every active tenant, and
operators pull cross-tenant invoice reports. Forcing it through the per-request
tenant filter would fight the feature. The trade-off is that billing handlers
own their tenant scoping: every query that should be tenant-bound must say so
(.Where(x => x.TenantId == current)), because nothing does it for them.
The plan catalogue (BillingPlan) goes further and is IGlobalEntity — a single
platform-wide list of plans shared by all tenants. See the Billing module.
Per-tenant migrations & provisioning
Because each tenant can have its own schema (and optionally its own database), migrations run per tenant. The DbMigrator applies the tenant catalog first, then walks each tenant’s per-module schema, serialized by a Postgres advisory lock so two migrator instances can’t collide.
New tenants are provisioned asynchronously. CreateTenantCommand returns
immediately, buffers the operator-supplied admin password in
ITenantInitialPasswordBuffer (a singleton — there is no hard-coded default
password), and queues a Hangfire job that walks ordered, resumable steps
(Database → Migrations → Seeding → CacheWarm). A tenant is only activated once
provisioning reaches Completed; a failed step is resumable via
RetryTenantProvisioning. Full lifecycle in the Multitenancy module.
Testing isolation
Isolation is verified, not assumed. Integration tests at
src/Tests/Integration.Tests/Tests/Multitenancy/ assert that data created under
tenant A is invisible to tenant B, that the header override is gated to the root
operator, and that seeding is tenant-scoped.
What tenancy is NOT
- Tenant isolation is not a security boundary against compromised code. A bug that calls
IgnoreQueryFilters()returns cross-tenant data. Architecture tests can catch obvious cases; code review catches the rest. Keep the bypass calls audited. - Tenant context is not propagated to outbound HTTP calls automatically. When you call a third-party API on behalf of a tenant, attach the tenant id explicitly to the request (header, OAuth scope, audit metadata).
- Tenant identity is not user identity. A user can belong to multiple tenants (the operator impersonation case). Use
ICurrentUser.GetUserId()for user identity; useICurrentUser.GetTenant()for the resolved tenant of the current request.
Related
- Multitenancy module — the module that owns tenant lifecycle.
- Billing module — the documented cross-tenant exception.
- Database Migrations — per-tenant migration ordering.
- Persistence —
BaseDbContextand the auto-applied filters. - Jobs —
FshJobFilterand tenant restoration. - Webhooks module — the cross-thread “restore tenant context manually” pattern.