Skip to content

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.

  • A working Granit application with Granit.Persistence
  • PostgreSQL (examples use UseNpgsql(), but any EF Core provider works)
Terminal window
dotnet add package Granit.MultiTenancy

Add 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 Authorization
app.UseAuthorization();
app.Run();

Add the configuration to appsettings.json:

{
"MultiTenancy": {
"IsEnabled": true,
"TenantIdClaimType": "tenant_id",
"TenantIdHeaderName": "X-Tenant-Id",
"HeaderTrustMode": "Unrestricted",
"DomainTemplate": "{0}.myapp.com",
"ValidateTenantExistence": true
}
}
OptionDefaultDescription
IsEnabledtrueEnable 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)
ValidateTenantExistencetrueVerify resolved tenant exists in tenant store (prevents phantom tenants)
DomainTemplatenullDomain template with {0} placeholder (e.g., "{0}.myapp.com"). null = disabled
QueryStringParamNamenullQuery string param name for dev/debug (null = disabled). Set "__tenant" in appsettings.Development.json

Four resolvers are registered by default, executed in Order sequence (first match wins):

  1. DomainTenantResolver (Order 50) — extracts subdomain from Host header, resolves via ITenantReader. Enabled when DomainTemplate is set
  2. HeaderTenantResolver (Order 100) — reads the X-Tenant-Id header, used for service-to-service calls
  3. JwtClaimTenantResolver (Order 200) — reads the tenant_id claim from the JWT token, used for user-authenticated requests via Keycloak
  4. QueryStringTenantResolver (Order 300) — reads ?__tenant={guid} from the query string, intended for development and debugging

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.

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”
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);
}
}
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 disposed

Use 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>();
// 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);
}
var tenant = Substitute.For<ICurrentTenant>();
tenant.IsAvailable.Returns(true);
tenant.Id.Returns(Guid.NewGuid());
var service = new PatientService(tenant, db);
factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration(config =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["MultiTenancy:IsEnabled"] = "false"
});
});
});
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:

Terminal window
dotnet add package Granit.MultiTenancy.Provisioning

Add 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.

Confirm tenant isolation is working by sending requests with different tenant headers:

Terminal window
# Request as tenant A
curl -s http://localhost:5000/api/v1/products \
-H "X-Tenant-Id: tenant-a" | jq 'length'
# → 3 (only tenant A products)
# Request as tenant B
curl -s http://localhost:5000/api/v1/products \
-H "X-Tenant-Id: tenant-b" | jq 'length'
# → 1 (only tenant B products)