Skip to content

Multi-Tenancy

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]
ComponentFileRole
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.

ComponentFileRole
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
StrategyFileMechanism
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
ComponentFileRole
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;
ProblemSolution
GDPR/ISO 27001: strict data isolation per organization3 isolation strategies cover all cases (cost vs security)
Modules that read the tenant without depending on Granit.MultiTenancySoft dependency via Granit.Core.MultiTenancy + NullTenantContext
Loss of tenant context in asynchronous processingPropagation 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);
return patients;
}
}
// Temporary tenant switch (admin operation)
public async Task MigrateTenantDataAsync(
Guid sourceTenantId,
Guid targetTenantId,
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
}