Skip to content

Granit.MultiTenancy

Granit.MultiTenancy adds HTTP-based tenant resolution to the framework. A pipeline of pluggable resolvers extracts the tenant from headers or JWT claims, sets an AsyncLocal context for the entire request, and EF Core query filters handle the rest. Three isolation strategies available — from shared database to database-per-tenant.

Single package — isolation strategies are configured in Granit.Persistence.

PackageRoleDepends on
Granit.MultiTenancyTenant resolution, CurrentTenant, middlewareGranit.Core
[DependsOn(typeof(GranitMultiTenancyModule))]
public class AppModule : GranitModule { }
{
"MultiTenancy": {
"IsEnabled": true,
"TenantIdClaimType": "tenant_id",
"TenantIdHeaderName": "X-Tenant-Id"
}
}

Pipeline order is critical:

app.UseAuthentication();
app.UseGranitMultiTenancy(); // After auth, before authorization
app.UseAuthorization();

Resolvers execute in Order (ascending). First non-null result wins.

flowchart LR
    R[Request] --> H{Header?}
    H -->|X-Tenant-Id| T[TenantInfo]
    H -->|missing| J{JWT claim?}
    J -->|tenant_id| T
    J -->|missing| N[No tenant]
    T --> M[Middleware sets AsyncLocal]
    M --> A[Request continues]
ResolverOrderSource
HeaderTenantResolver100X-Tenant-Id HTTP header
JwtClaimTenantResolver200tenant_id JWT claim
public sealed class SubdomainTenantResolver(
ITenantStore tenantStore) : ITenantResolver
{
public int Order => 50; // Before header resolver
public async Task<TenantInfo?> ResolveAsync(
HttpContext context, CancellationToken cancellationToken = default)
{
string host = context.Request.Host.Host;
string subdomain = host.Split('.')[0];
var tenant = await tenantStore
.FindBySubdomainAsync(subdomain, cancellationToken)
.ConfigureAwait(false);
return tenant is not null
? new TenantInfo(tenant.Id, tenant.Name)
: null;
}
}

Register it in DI — the pipeline auto-discovers all ITenantResolver implementations.

The real implementation uses AsyncLocal<TenantInfo?> for thread-safe, per-async-flow isolation:

// In any service — constructor injection
public class PatientService(ICurrentTenant currentTenant, AppDbContext db)
{
public async Task<List<Patient>> GetAllAsync(CancellationToken cancellationToken)
{
// EF Core query filter automatically applies WHERE TenantId = @tenantId
return await db.Patients
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
}

Switch tenant context for background jobs or cross-tenant admin operations:

public class TenantMigrationService(ICurrentTenant currentTenant)
{
public async Task MigrateDataAsync(
Guid sourceTenantId, Guid targetTenantId, CancellationToken cancellationToken)
{
using (currentTenant.Change(sourceTenantId))
{
var data = await LoadDataAsync(cancellationToken).ConfigureAwait(false);
using (currentTenant.Change(targetTenantId))
{
await SaveDataAsync(data, cancellationToken).ConfigureAwait(false);
}
// Back to source tenant
}
// Back to original tenant (or no tenant)
}
}

Mark endpoints that don’t require a tenant context:

[AllowAnonymousTenant]
app.MapGet("/health", () => Results.Ok());

Granit.ApiDocumentation automatically excludes the X-Tenant-Id header from OpenAPI docs for these endpoints.

Three strategies configured in Granit.Persistence:

StrategyIsolationQuery filterConnection
SharedDatabaseWHERE TenantId = @idAutomatic via ApplyGranitConventionsSingle connection string
SchemaPerTenantPostgreSQL SET search_pathPer-schema tablesSingle connection, per-request schema
DatabasePerTenantSeparate databaseN/A (physical isolation)Per-tenant connection from Vault

Default strategy. All tenants share one database, isolated by EF Core query filters on IMultiTenant entities.

{
"TenantIsolation": {
"Strategy": "SharedDatabase"
}
}

No additional setup — ApplyGranitConventions handles the TenantId filter.

When Wolverine dispatches messages, the OutgoingContextMiddleware injects X-Tenant-Id into the message envelope. The TenantContextBehavior restores it on the receiving side:

HTTP Request (Tenant A) → Wolverine handler → background job
X-Tenant-Id: A ───────────────────→ ICurrentTenant.Id = A

This ensures tenant isolation across async message processing without manual propagation.

PropertyDefaultDescription
IsEnabledtrueEnable/disable tenant resolution
TenantIdClaimType"tenant_id"JWT claim name for tenant ID
TenantIdHeaderName"X-Tenant-Id"HTTP header name for tenant ID
CategoryKey typesPackage
ModuleGranitMultiTenancyModuleGranit.MultiTenancy
Core (in Granit.Core)ICurrentTenant, AllowAnonymousTenantAttributeGranit.Core
ImplementationCurrentTenant, TenantInfo, ITenantInfoGranit.MultiTenancy
ResolutionITenantResolver, HeaderTenantResolver, JwtClaimTenantResolver, TenantResolverPipelineGranit.MultiTenancy
MiddlewareTenantResolutionMiddlewareGranit.MultiTenancy
OptionsMultiTenancyOptionsGranit.MultiTenancy
ExtensionsAddGranitMultiTenancy(), UseGranitMultiTenancy()Granit.MultiTenancy