Multi-tenancy allows a single application instance to serve multiple
organizations (tenants) with strict data isolation. Each request is associated
with a tenant through a resolution pipeline, and this information flows across
all layers — including asynchronous Wolverine processing.
Granit implements three isolation strategies and a soft dependency mechanism:
ICurrentTenant is available in all modules without a direct dependency on
Granit.MultiTenancy.
flowchart TD
REQ[HTTP Request] --> PIPE[TenantResolverPipeline]
PIPE --> HR["HeaderTenantResolver<br/>(Order = 100)"]
HR -->|found| CTX[CurrentTenant via AsyncLocal]
HR -->|not found| JR["JwtClaimTenantResolver<br/>(Order = 200)"]
JR -->|found| CTX
JR -->|not found| NULL["NullTenantContext<br/>IsAvailable = false"]
CTX --> STRAT{Isolation strategy}
STRAT -->|SharedDatabase| QF["EF Core Query Filter<br/>WHERE TenantId = @tid"]
STRAT -->|SchemaPerTenant| SP["SET search_path TO<br/>tenant_{tid}"]
STRAT -->|DatabasePerTenant| DB["Dedicated connection<br/>string per tenant"]
CTX --> OCM["OutgoingContextMiddleware<br/>injects X-Tenant-Id"]
OCM --> WOL["Wolverine Outbox"]
WOL --> TCB["TenantContextBehavior<br/>restores ICurrentTenant"]
TCB --> BH[Background Handler]
Component File Role ICurrentTenantsrc/Granit.Core/MultiTenancy/ICurrentTenant.csMinimal interface: Id, IsAvailable, Change() NullTenantContextsrc/Granit.Core/MultiTenancy/NullTenantContext.csNull Object: IsAvailable = false, no-op operations
All modules resolve ICurrentTenant via Granit.Core.MultiTenancy —
no [DependsOn(GranitMultiTenancyModule)] required.
Component File Role CurrentTenantsrc/Granit.MultiTenancy/CurrentTenant.csAsyncLocal<TenantInfo?> implementation + TenantScope (IDisposable)TenantResolverPipelinesrc/Granit.MultiTenancy/Pipeline/TenantResolverPipeline.csOrdered chain of ITenantResolver HeaderTenantResolversrc/Granit.MultiTenancy/Resolvers/HeaderTenantResolver.csResolution via X-Tenant-Id (priority 100) JwtClaimTenantResolversrc/Granit.MultiTenancy/Resolvers/JwtClaimTenantResolver.csResolution via JWT claim (priority 200) TenantResolutionMiddlewaresrc/Granit.MultiTenancy/Middleware/TenantResolutionMiddleware.csASP.NET Core middleware
Strategy File Mechanism SharedDatabasesrc/Granit.Persistence/MultiTenancy/SharedDatabaseDbContextFactory.csEF Core query filters on TenantId SchemaPerTenantsrc/Granit.Persistence/MultiTenancy/TenantPerSchemaDbContextFactory.csSET search_path TO tenant_{id} (PostgreSQL)DatabasePerTenantsrc/Granit.Persistence/MultiTenancy/TenantPerDatabaseDbContextFactory.csDedicated connection string per tenant TenantIsolationStrategysrc/Granit.Persistence/MultiTenancy/TenantIsolationStrategy.csSelection enum
Component File Role OutgoingContextMiddlewaresrc/Granit.Wolverine/Middleware/OutgoingContextMiddleware.csInjects X-Tenant-Id into outgoing Wolverine envelopes TenantContextBehaviorsrc/Granit.Wolverine/Behaviors/TenantContextBehavior.csRestores ICurrentTenant in background handlers
Any code accessing ICurrentTenant.Id must check IsAvailable first:
// Correct pattern (src/Granit.Features/Checker/FeatureChecker.cs:46)
Guid? tenantId = currentTenant ? . IsAvailable == true ? currentTenant . Id : null ;
Problem Solution GDPR/ISO 27001: strict data isolation per organization 3 isolation strategies cover all cases (cost vs security) Modules that read the tenant without depending on Granit.MultiTenancy Soft dependency via Granit.Core.MultiTenancy + NullTenantContext Loss of tenant context in asynchronous processing Propagation via Wolverine headers + restoration by behaviors Need to temporarily switch tenant (cross-tenant admin) ICurrentTenant.Change() returns an IDisposable scope
// Shared Database: query filters apply automatically
public sealed class PatientService (AppDbContext db, ICurrentTenant tenant)
public async Task<List<Patient>> GetAllAsync (CancellationToken cancellationToken)
// EF Core automatically adds WHERE TenantId = @currentTenantId
List<Patient> patients = await db . Patients . ToListAsync (ct);
// Temporary tenant switch (admin operation)
public async Task MigrateTenantDataAsync (
ICurrentTenant currentTenant,
CancellationToken cancellationToken)
using ( currentTenant . Change (sourceTenantId))
// Read in the source tenant context
List<Patient> patients = await db . Patients . ToListAsync (ct);
// The previous tenant is automatically restored here