Configure Multi-Tenancy — Tenant Setup
Granit.MultiTenancy provides per-request tenant isolation via ICurrentTenant.
The middleware reads a JWT claim or HTTP header, activates the tenant context for
the duration of the request, and restores it afterward. This guide covers setup,
the three isolation strategies, and common patterns.
Prerequisites
Section titled “Prerequisites”- A working Granit application with
Granit.Persistence - PostgreSQL (examples use
UseNpgsql(), but any EF Core provider works)
Step 1 — Install the package
Section titled “Step 1 — Install the package”dotnet add package Granit.MultiTenancyAdd the module dependency in your host module:
using Granit.Modularity;using Granit.MultiTenancy;
[DependsOn(typeof(GranitMultiTenancyModule))]public sealed class MyAppHostModule : GranitModule { }Step 2 — Configure the middleware pipeline
Section titled “Step 2 — Configure the middleware pipeline”The tenant resolution middleware must be placed after UseAuthentication()
(so HttpContext.User is populated) and before UseAuthorization():
var app = builder.Build();
await app.UseGranitAsync();
app.UseAuthentication();app.UseGranitMultiTenancy(); // after Authentication, before Authorizationapp.UseAuthorization();
app.Run();Step 3 — Configure tenant resolution
Section titled “Step 3 — Configure tenant resolution”Add the configuration to appsettings.json:
{ "MultiTenancy": { "IsEnabled": true, "TenantIdClaimType": "tenant_id", "TenantIdHeaderName": "X-Tenant-Id", "HeaderTrustMode": "Unrestricted", "DomainTemplate": "{0}.myapp.com", "ValidateTenantExistence": true }}| Option | Default | Description |
|---|---|---|
IsEnabled | true | Enable or disable tenant resolution |
TenantIdClaimType | "tenant_id" | JWT claim name containing the tenant ID |
TenantIdHeaderName | "X-Tenant-Id" | HTTP header name for explicit tenant specification |
HeaderTrustMode | "Unrestricted" | Unrestricted (accept header as-is) or CrossValidate (header must match JWT claim) |
ValidateTenantExistence | true | Verify resolved tenant exists in tenant store (prevents phantom tenants) |
DomainTemplate | null | Domain template with {0} placeholder (e.g., "{0}.myapp.com"). null = disabled |
QueryStringParamName | null | Query string param name for dev/debug (null = disabled). Set "__tenant" in appsettings.Development.json |
Resolution order
Section titled “Resolution order”Four resolvers are registered by default, executed in Order sequence (first match wins):
- DomainTenantResolver (Order 50) — extracts subdomain from
Hostheader, resolves viaITenantReader. Enabled whenDomainTemplateis set - HeaderTenantResolver (Order 100) — reads the
X-Tenant-Idheader, used for service-to-service calls - JwtClaimTenantResolver (Order 200) — reads the
tenant_idclaim from the JWT token, used for user-authenticated requests via Keycloak - QueryStringTenantResolver (Order 300) — reads
?__tenant={guid}from the query string, intended for development and debugging
Step 4 — Choose an isolation strategy
Section titled “Step 4 — Choose an isolation strategy”Granit supports three data isolation strategies. Choose based on your security requirements and infrastructure constraints.
All tenants share a single database. Isolation is enforced through a global
TenantId query filter on every IMultiTenant entity.
{ "TenantIsolation": { "Strategy": "SharedDatabase" }}builder.Services.AddDbContextFactory<AppDbContext>( options => options.UseNpgsql( builder.Configuration.GetConnectionString("Default")));This is the default strategy and the simplest to operate. It supports an unlimited number of tenants with minimal infrastructure cost.
Each tenant gets a dedicated PostgreSQL schema. The search_path is set
unconditionally on every connection.
{ "TenantIsolation": { "Strategy": "SchemaPerTenant" }}builder.Services.AddTenantPerSchemaDbContext<AppDbContext>( options => options.UseNpgsql(connectionString), schema => schema.Prefix = "tenant_");Supports up to approximately 1,000 tenants per database. Enables per-tenant
backup with pg_dump -n.
Each tenant gets a completely separate database. Connection strings are resolved dynamically per tenant.
{ "TenantIsolation": { "Strategy": "DatabasePerTenant" }}builder.Services.AddSingleton<ITenantConnectionStringProvider, VaultConnectionStringProvider>();
builder.Services.AddTenantPerDatabaseDbContext<AppDbContext>( (options, connectionString) => options.UseNpgsql(connectionString));Provides the strongest isolation — recommended for ISO 27001 environments requiring physical separation. Supports up to approximately 200 tenants (use PgBouncer for higher counts).
Configurable strategy with unified facade
Section titled “Configurable strategy with unified facade”For applications that need to switch strategies via configuration:
builder.Services.AddGranitIsolatedDbContext<AppDbContext>( configureShared: options => options.UseNpgsql( builder.Configuration.GetConnectionString("Default")), configureDatabasePerTenant: (options, connectionString) => options.UseNpgsql(connectionString), configureSchemaPerTenant: options => options.UseNpgsql( builder.Configuration.GetConnectionString("Default")), configureTenantSchema: schema => { schema.NamingConvention = TenantSchemaNamingConvention.TenantId; schema.Prefix = "tenant_"; });The IsolatedDbContextFactory<TContext> reads the strategy from appsettings.json
and delegates to the appropriate keyed factory at runtime.
Step 5 — Use ICurrentTenant in your code
Section titled “Step 5 — Use ICurrentTenant in your code”In a service (constructor injection)
Section titled “In a service (constructor injection)”public sealed class PatientService( ICurrentTenant currentTenant, AppDbContext db){ public async Task<IReadOnlyList<Patient>> GetPatientsAsync( CancellationToken cancellationToken) { if (!currentTenant.IsAvailable) { throw new InvalidOperationException("No tenant context."); }
return await db.Patients .ToListAsync(cancellationToken); }}In a Wolverine handler (method injection)
Section titled “In a Wolverine handler (method injection)”public static async Task<IResult> Handle( GetPatientsQuery query, AppDbContext db, ICurrentTenant currentTenant, CancellationToken cancellationToken){ if (!currentTenant.IsAvailable) { return TypedResults.Problem( detail: "Tenant context required.", statusCode: StatusCodes.Status400BadRequest); }
var patients = await db.Patients .ToListAsync(cancellationToken);
return TypedResults.Ok(patients);}Temporary override (background jobs, tests)
Section titled “Temporary override (background jobs, tests)”using IDisposable scope = currentTenant.Change(tenantId, tenantName);await ProcessTenantDataAsync();// Previous tenant context is restored when the scope is disposedUse this pattern for IHostedService implementations that process multiple tenants
sequentially, or in integration tests that simulate tenant-specific requests.
Step 6 — Configure host schema (SchemaPerTenant only)
Section titled “Step 6 — Configure host schema (SchemaPerTenant only)”When using SchemaPerTenant, host modules (identity, audit, jobs) need an explicit
schema. Configure it in appsettings.json:
{ "TenantIsolation": { "Strategy": "SchemaPerTenant", "HostSchema": "host" }}All host modules automatically use the host schema via GranitDbDefaults.HostDbSchema.
Tenant modules use unqualified table names, letting SET search_path route to the
correct tenant schema.
Create the host schema at startup (EF Core never creates schemas automatically):
await SchemaEnsurer.EnsureSchemasAsync(dbContext, cancellationToken, "host");Step 7 — Add a custom resolver (optional)
Section titled “Step 7 — Add a custom resolver (optional)”The four built-in resolvers cover most use cases. For additional strategies
(e.g., cookie-based), implement ITenantResolver:
public sealed class CookieTenantResolver : ITenantResolver{ public int Order => 150; // Between header (100) and JWT (200)
public Task<TenantInfo?> ResolveAsync( HttpContext context, CancellationToken cancellationToken = default) { if (!context.Request.Cookies.TryGetValue("tenant_id", out string? value)) return Task.FromResult<TenantInfo?>(null);
if (!Guid.TryParse(value, out Guid tenantId)) return Task.FromResult<TenantInfo?>(null);
return Task.FromResult<TenantInfo?>(new TenantInfo(tenantId)); }}Register it in DI:
services.AddScoped<ITenantResolver, CookieTenantResolver>();ICurrentTenant API reference
Section titled “ICurrentTenant API reference”// Namespace: Granit.MultiTenancy (in Granit package)public interface ICurrentTenant{ bool IsAvailable { get; } Guid? Id { get; } string? Name { get; } IDisposable Change(Guid? id, string? name = null);}Testing
Section titled “Testing”Mock a tenant in unit tests
Section titled “Mock a tenant in unit tests”var tenant = Substitute.For<ICurrentTenant>();tenant.IsAvailable.Returns(true);tenant.Id.Returns(Guid.NewGuid());
var service = new PatientService(tenant, db);Disable in integration tests
Section titled “Disable in integration tests”factory.WithWebHostBuilder(builder =>{ builder.ConfigureAppConfiguration(config => { config.AddInMemoryCollection(new Dictionary<string, string?> { ["MultiTenancy:IsEnabled"] = "false" }); });});Step 8 — Enable automatic tenant provisioning (recommended)
Section titled “Step 8 — Enable automatic tenant provisioning (recommended)”When a tenant is created at runtime, the framework can automatically provision its database resources (schema, tables, seed data). Install the Wolverine integration:
dotnet add package Granit.MultiTenancy.ProvisioningAdd the module dependency:
[DependsOn( typeof(GranitMultiTenancyProvisioningModule), // ... other dependencies)]public sealed class AppHostModule : GranitModule { }That’s it. When TenantCreatedEvent fires, the AutoTenantProvisioner discovers
all DbContext types registered via AddGranitIsolatedDbContext<T>(), creates
schemas, runs migrations, and executes ITenantDataSeedContributor implementations.
Verify
Section titled “Verify”Confirm tenant isolation is working by sending requests with different tenant headers:
# Request as tenant Acurl -s http://localhost:5000/api/v1/products \ -H "X-Tenant-Id: tenant-a" | jq 'length'# → 3 (only tenant A products)
# Request as tenant Bcurl -s http://localhost:5000/api/v1/products \ -H "X-Tenant-Id: tenant-b" | jq 'length'# → 1 (only tenant B products)Next steps
Section titled “Next steps”- Configure caching — set up distributed caching with tenant-aware keys
- Create a module — build a tenant-aware module from scratch
- Multi-tenancy reference — overview, resolvers, isolation strategies, cache isolation, host/tenant separation