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.
How Resolution Works
Section titled “How Resolution Works”HTTP Pipeline
Section titled “HTTP Pipeline”In HTTP contexts, tenant resolution follows this flow:
HTTP Request | vTenantResolutionMiddleware |--- resolves ITenantResolver from DI (scoped) |--- calls resolver.ResolveAsync() |--- populates MutableTenantContext.TenantId | vRouting / Endpoint Execution |--- ITenantContext is available to all downstream services |--- IQueryFilter<T> (TenantFilter) reads ITenantContext.TenantId | vResponseThe 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.
Non-HTTP Contexts
Section titled “Non-HTTP Contexts”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/awaitcontinuations. - Supports nesting with correct restore semantics.
- Is independent of DI (static API, no service resolution needed).
Built-In Resolvers
Section titled “Built-In Resolvers”HeaderTenantResolver
Section titled “HeaderTenantResolver”Reads the tenant ID from an HTTP request header.
app.UseMultiTenancy(mt => mt.UseHeader()); // default: X-Tenant-Idapp.UseMultiTenancy(mt => mt.UseHeader("Tenant-Key")); // custom header nameConfiguration: 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.1X-Tenant-Id: acme-corpClaimTenantResolver
Section titled “ClaimTenantResolver”Reads the tenant ID from a JWT claim on the authenticated user.
app.UseMultiTenancy(mt => mt.UseClaim()); // default: tenant_idapp.UseMultiTenancy(mt => mt.UseClaim("org_id")); // custom claim typeConfiguration: 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.
SubdomainTenantResolver
Section titled “SubdomainTenantResolver”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).
| Host | Resolved Tenant |
|---|---|
acme.app.com | acme |
beta.staging.app.com | beta |
app.com | null (only 2 segments) |
localhost | null (only 1 segment) |
When to use: SaaS applications with vanity subdomains per tenant.
RouteTenantResolver
Section titled “RouteTenantResolver”Reads the tenant ID from a route parameter.
app.UseMultiTenancy(mt => mt.UseRoute()); // default: {tenantId}app.UseMultiTenancy(mt => mt.UseRoute("orgSlug")); // custom parameter nameConfiguration: 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.
SingleTenantResolver
Section titled “SingleTenantResolver”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 IDWhen 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 atWarninglevel 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.
Custom Resolvers
Section titled “Custom Resolvers”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.
Configuration Reference
Section titled “Configuration Reference”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.
Middleware Ordering
Section titled “Middleware Ordering”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.
Logging
Section titled “Logging”Both TenantResolutionMiddleware and CompositeTenantResolver use [LoggerMessage] for zero-allocation structured logging:
| Level | Message | When |
|---|---|---|
Debug | Tenant '{TenantId}' resolved for {RequestPath} | Tenant successfully resolved |
Trace | No tenant resolved for {RequestPath} | No resolver returned a value |
Warning | Tenant resolver {ResolverName} failed, continuing to next resolver | A resolver threw an exception |
Warning | No tenant resolver succeeded out of {ResolverCount} registered resolver(s). Returning null tenant ID | All resolvers returned null |
DI Registrations
Section titled “DI Registrations”AddPragmaticMultiTenancy() registers:
| Service | Lifetime | Implementation |
|---|---|---|
MutableTenantContext | Scoped | Concrete class |
ITenantContext | Scoped | Delegates to MutableTenantContext |
ITenantResolver | Depends on strategy | SingleTenantResolver (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.