Skip to content

Tenant Resolution

Tenant resolution is the process of determining which tenant a request belongs to. Pragmatic.MultiTenancy provides a pluggable strategy system with four built-in HTTP resolvers and support for custom implementations.

In HTTP contexts, tenant resolution follows this flow:

HTTP Request
|
v
TenantResolutionMiddleware
|--- resolves ITenantResolver from DI (scoped)
|--- calls resolver.ResolveAsync()
|--- populates MutableTenantContext.TenantId
|
v
Routing / Endpoint Execution
|--- ITenantContext is available to all downstream services
|--- IQueryFilter<T> (TenantFilter) reads ITenantContext.TenantId
|
v
Response

The middleware runs early in the pipeline (before routing) so that the tenant context is available everywhere. It resolves ITenantResolver and MutableTenantContext from the scoped DI container and populates the context with the resolved tenant ID.

For background jobs, seed scripts, message handlers, or tests, use TenantScope:

using var scope = TenantScope.BeginScope("tenant-123", "Acme Corp");
// All code in this async flow sees TenantId = "tenant-123"
await ProcessBackgroundJobAsync();

TenantScope uses AsyncLocal<T> internally, so it:

  • Flows through async/await continuations.
  • Supports nesting with correct restore semantics.
  • Is independent of DI (static API, no service resolution needed).

Reads the tenant ID from an HTTP request header.

app.UseMultiTenancy(mt => mt.UseHeader()); // default: X-Tenant-Id
app.UseMultiTenancy(mt => mt.UseHeader("Tenant-Key")); // custom header name

Configuration: MultiTenancyOptions.TenantHeaderName (default: "X-Tenant-Id").

When to use: API-to-API communication, development/testing, B2B APIs where the caller controls the header.

Example request:

GET /api/invoices HTTP/1.1
X-Tenant-Id: acme-corp

Reads the tenant ID from a JWT claim on the authenticated user.

app.UseMultiTenancy(mt => mt.UseClaim()); // default: tenant_id
app.UseMultiTenancy(mt => mt.UseClaim("org_id")); // custom claim type

Configuration: MultiTenancyOptions.TenantClaimType (default: "tenant_id").

When to use: Applications where the identity provider embeds the tenant ID in the JWT token. The most secure strategy since the tenant is cryptographically bound to the token.

Requirement: Authentication middleware must run before tenant resolution for claims to be available.

Extracts the tenant ID from the first segment of the request’s host.

app.UseMultiTenancy(mt => mt.UseSubdomain());

Resolution logic: splits the host by . and uses the first segment if there are at least 3 parts (subdomain.domain.tld).

HostResolved Tenant
acme.app.comacme
beta.staging.app.combeta
app.comnull (only 2 segments)
localhostnull (only 1 segment)

When to use: SaaS applications with vanity subdomains per tenant.

Reads the tenant ID from a route parameter.

app.UseMultiTenancy(mt => mt.UseRoute()); // default: {tenantId}
app.UseMultiTenancy(mt => mt.UseRoute("orgSlug")); // custom parameter name

Configuration: MultiTenancyOptions.TenantRouteParameter (default: "tenantId").

When to use: APIs structured as /{tenantId}/invoices, /{tenantId}/orders, etc.

Example route: GET /acme-corp/invoices with route template /{tenantId}/invoices.

Returns a fixed tenant ID. This is the default when no strategy is configured.

app.UseMultiTenancy(mt => mt.UseSingleTenant()); // default: "default"
app.UseMultiTenancy(mt => mt.UseSingleTenant("my-app")); // custom fixed ID

When to use: Single-tenant deployments. Registered as Singleton for zero per-request overhead.

Composite Resolution (Chain of Responsibility)

Section titled “Composite Resolution (Chain of Responsibility)”

When multiple resolvers are registered, they are wrapped in a CompositeTenantResolver that tries each one in registration order:

app.UseMultiTenancy(mt => mt
.UseHeader() // 1st: try X-Tenant-Id header
.UseClaim() // 2nd: try JWT claim
.UseSingleTenant("demo") // 3rd: fallback to "demo"
);

Behavior:

  • Resolvers are tried sequentially in registration order.
  • The first resolver to return a non-null, non-empty string wins.
  • If a resolver throws an exception (not OperationCanceledException), the error is logged at Warning level and the chain continues to the next resolver.
  • If all resolvers return null, the composite returns null and logs a warning.

This enables resilient tenant resolution with graceful fallback chains.

Implement ITenantResolver for application-specific resolution logic:

public sealed class ApiKeyTenantResolver(
IHttpContextAccessor accessor,
ITenantStore tenantStore) : ITenantResolver
{
public async ValueTask<string?> ResolveAsync(CancellationToken cancellationToken = default)
{
var apiKey = accessor.HttpContext?.Request.Headers["X-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey))
return null;
return await tenantStore.GetTenantIdByApiKeyAsync(apiKey, cancellationToken);
}
}

Register it via the builder:

app.UseMultiTenancy(mt => mt.UseResolver<ApiKeyTenantResolver>());

Custom resolvers participate in the composite chain like built-in ones.

All options are centralized in MultiTenancyOptions:

services.Configure<MultiTenancyOptions>(options =>
{
options.DefaultTenantId = "primary";
options.TenantHeaderName = "X-Organization-Id";
options.TenantClaimType = "org_id";
options.TenantRouteParameter = "orgSlug";
options.RequireTenant = true;
});

The builder methods (UseHeader, UseClaim, UseRoute) accept optional parameters that configure these options automatically. Direct Configure<MultiTenancyOptions> is also supported for advanced scenarios.

TenantResolutionMiddleware should run early in the pipeline, after authentication but before routing:

Authentication (sets ClaimsPrincipal)
|
TenantResolution (reads claims/headers, sets ITenantContext)
|
Routing (ITenantContext available to endpoints, filters, services)

In Pragmatic.Design applications using PragmaticApp.RunAsync, the middleware is added automatically in the correct position. The SG-generated host wiring handles this.

Both TenantResolutionMiddleware and CompositeTenantResolver use [LoggerMessage] for zero-allocation structured logging:

LevelMessageWhen
DebugTenant '{TenantId}' resolved for {RequestPath}Tenant successfully resolved
TraceNo tenant resolved for {RequestPath}No resolver returned a value
WarningTenant resolver {ResolverName} failed, continuing to next resolverA resolver threw an exception
WarningNo tenant resolver succeeded out of {ResolverCount} registered resolver(s). Returning null tenant IDAll resolvers returned null

AddPragmaticMultiTenancy() registers:

ServiceLifetimeImplementation
MutableTenantContextScopedConcrete class
ITenantContextScopedDelegates to MutableTenantContext
ITenantResolverDepends on strategySingleTenantResolver (Singleton) or HTTP resolvers (Scoped)

All core registrations use TryAdd to avoid duplicate registrations when both SG auto-registration and explicit UseMultiTenancy() are present. The resolver registration does not use TryAdd, so UseMultiTenancy() correctly overrides the SG default.