Configure multi-tenancy
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.Core.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" }}| 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 |
Resolution order
Section titled “Resolution order”Two resolvers are registered by default, executed in Order sequence (first match wins):
- 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
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 — Add a custom resolver
Section titled “Step 6 — Add a custom resolver”To resolve tenants from subdomains or other sources, implement ITenantResolver:
public sealed class SubdomainTenantResolver : ITenantResolver{ public int Order => 50; // runs before Header (100) and JWT (200)
public Task<TenantInfo?> ResolveAsync( HttpContext context, CancellationToken cancellationToken = default) { var host = context.Request.Host.Host; var subdomain = host.Split('.')[0];
if (Guid.TryParse(subdomain, out var tenantId)) { return Task.FromResult<TenantInfo?>( new TenantInfo(tenantId)); }
return Task.FromResult<TenantInfo?>(null); }}Register it in your module:
services.AddSingleton<ITenantResolver, SubdomainTenantResolver>();ICurrentTenant API reference
Section titled “ICurrentTenant API reference”// Namespace: Granit.Core.MultiTenancy (in Granit.Core 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" }); });});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 — full API and architecture details