Skip to content

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.

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

Add the configuration to appsettings.json:

{
"MultiTenancy": {
"IsEnabled": true,
"TenantIdClaimType": "tenant_id",
"TenantIdHeaderName": "X-Tenant-Id"
}
}
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

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

  1. HeaderTenantResolver (Order 100) — reads the X-Tenant-Id header, used for service-to-service calls
  2. JwtClaimTenantResolver (Order 200) — reads the tenant_id claim from the JWT token, used for user-authenticated requests via Keycloak

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.

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>();
// 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);
}
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"
});
});
});