Skip to content

Pragmatic.MultiTenancy

Shared-schema multi-tenancy for the Pragmatic.Design ecosystem. Provides tenant resolution, scoping, and automatic query filtering with zero configuration for single-tenant apps and pluggable strategies for multi-tenant SaaS.

Multi-tenant SaaS applications need to isolate data between tenants. Without framework support, every query must manually filter by tenant, every write must manually assign the tenant, and every background job must manually propagate the tenant context. Miss one WHERE TenantId = @current clause and you have a cross-tenant data leak.

// Without Pragmatic: manual tenant filtering everywhere
public async Task<List<Invoice>> GetInvoicesAsync(CancellationToken ct)
{
var tenantId = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
return await db.Invoices
.Where(i => i.TenantId == tenantId) // easy to forget
.ToListAsync(ct);
}

The problems compound across the stack:

  • Every query needs a WHERE clause. Forget one and you expose tenant A’s data to tenant B. Code reviews catch this sometimes. Automated testing catches this never (unless every test asserts on tenant isolation).
  • Every write needs tenant assignment. New entities must have TenantId set correctly. Some developers remember. Some do not. There is no compile-time safety net.
  • Background jobs have no tenant. A hosted service processing a queue message has no HTTP request. Where does the tenant come from? You need ambient context that flows through async continuations.
  • Caching leaks across tenants. A cache key of invoices:latest returns tenant A’s data when tenant B requests it. Every cache key needs tenant scoping.
  • Resolution strategy varies. Development uses headers. Production uses JWT claims. Some APIs use subdomains. You need a pluggable resolution chain without rewriting business logic.

With Pragmatic.MultiTenancy, you declare which entities are tenant-scoped. The source generator handles the filtering.

// 1. Mark entities with ITenantEntity
[Entity<Guid>]
[BelongsTo<CatalogBoundary>]
public partial class Invoice : IEntity<Guid>, ITenantEntity
{
public string Number { get; private set; } = "";
public string TenantId { get; set; } = ""; // auto-set, auto-filtered
}
// 2. Business logic is tenant-unaware -- no manual filtering
public class InvoiceService(IReadRepository<Invoice, Guid> invoices)
{
public async Task<List<Invoice>> GetInvoicesAsync(CancellationToken ct)
=> await invoices.QueryAsync(ct); // TenantFilter applied automatically
}

The SG generates a TenantFilter per entity. The middleware resolves the tenant. Business logic never touches tenant isolation. Zero manual WHERE clauses, zero cross-tenant data leaks.


The module is split into two packages:

PackageTargetDepends OnPurpose
Pragmatic.MultiTenancynet10.0Pragmatic.AbstractionsCore runtime: context, resolvers, scope, options
Pragmatic.MultiTenancy.AspNetCorenet10.0Core + Microsoft.AspNetCore.AppHTTP resolvers, middleware, builder

Abstractions (ITenantContext, ITenantResolver, ITenantEntity) live in Pragmatic.Abstractions so any module can depend on them without pulling in the full runtime.

Pragmatic.MultiTenancy uses shared-schema, row-level isolation:

  • All tenants share the same database and tables.
  • Entities that implement ITenantEntity get an automatic TenantId column.
  • The Source Generator produces a TenantFilter (priority 200) for each ITenantEntity. This filter is registered as an IQueryFilter<T> and applied automatically by the query pipeline, ensuring tenant isolation without manual WHERE clauses.
  • TenantId is set automatically on insert by the persistence layer.

This model is appropriate for most SaaS applications. Database-per-tenant isolation is supported at the infrastructure level but is outside the scope of this module.


<PackageReference Include="Pragmatic.MultiTenancy.AspNetCore" />

The Source Generator auto-detects the presence of Pragmatic.MultiTenancy and registers single-tenant mode by default. No configuration is needed for single-tenant apps.

2. Configure a Resolution Strategy (Multi-Tenant)

Section titled “2. Configure a Resolution Strategy (Multi-Tenant)”

In Program.cs, override the default with a resolution strategy:

await PragmaticApp.RunAsync(args, app =>
{
app.UseMultiTenancy(mt => mt.UseHeader());
});

Implement ITenantEntity on entities that need row-level tenant isolation:

[Entity<Guid>]
[BelongsTo<CatalogBoundary>]
public partial class Property : IEntity<Guid>, ITenantEntity
{
public string Name { get; private set; } = "";
// ITenantEntity - auto-set on insert, auto-filtered on read
public string TenantId { get; set; } = "";
}

The SG generates a Property.TenantFilter class that filters all queries to the current tenant automatically.

Inject ITenantContext anywhere in the pipeline:

public class MyService(ITenantContext tenantContext)
{
public void DoWork()
{
if (tenantContext.IsResolved)
{
var tenantId = tenantContext.TenantId;
// tenant-specific logic
}
}
}

Configure via MultiTenancyBuilder:

MethodSourceDefault Config
UseSingleTenant(id)Fixed value"default"
UseHeader(name)HTTP headerX-Tenant-Id
UseClaim(type)JWT claimtenant_id
UseSubdomain()First subdomain segmentacme.app.com -> acme
UseRoute(param)Route parametertenantId
UseResolver<T>()Custom ITenantResolverN/A

Multiple strategies can be registered. When more than one resolver is registered, CompositeTenantResolver tries them in registration order and returns the first non-null result. Individual resolver failures are logged and do not stop the chain.

app.UseMultiTenancy(mt => mt
.UseHeader() // try header first
.UseClaim() // fall back to JWT claim
.UseSingleTenant()); // final fallback
ScenarioStrategyWhy
Single-tenant deploymentUseSingleTenant()No overhead, zero configuration
API-first, multiple clientsUseHeader()Client controls tenant in each request
JWT-based authenticationUseClaim()Tenant embedded in token, tamper-proof
White-label SaaSUseSubdomain()Natural URL separation per tenant
Admin APIs managing tenantsUseRoute()Tenant in URL path for RESTful design
Complex logic (API key, DB lookup)UseResolver<T>()Full control over resolution
DevelopmentUseHeader() + UseSingleTenant()Header for testing, fallback for dev
HTTP Request arrives
|
v
TenantResolutionMiddleware
|
v
CompositeTenantResolver
|
+-- HeaderTenantResolver -> "X-Tenant-Id: acme" -> "acme"
|
+-- ClaimTenantResolver -> claim "tenant_id" -> "acme"
|
+-- SubdomainTenantResolver -> "acme.app.com" -> "acme"
|
+-- SingleTenantResolver -> (always) -> "default"
|
v
MutableTenantContext.TenantId = "acme"
|
v
All downstream services see ITenantContext.TenantId = "acme"

For background jobs, seed scripts, or tests where there is no HTTP request, use TenantScope:

using var scope = TenantScope.BeginScope("tenant-123");
// ITenantContext.TenantId is now "tenant-123" within this async flow
await myService.ProcessAsync();
// Scope is restored on dispose (supports nesting)

TenantScope uses AsyncLocal<T> so it flows through async continuations and supports nesting with correct restore-on-dispose semantics.

TenantScope supports nesting and correctly restores the previous scope on dispose:

using (TenantScope.BeginScope("tenant-a"))
{
// TenantId = "tenant-a"
using (TenantScope.BeginScope("tenant-b"))
{
// TenantId = "tenant-b"
}
// TenantId = "tenant-a" (restored)
}
// TenantId = null (restored)

TenantScope itself implements ITenantContext. It reads from the AsyncLocal state, so it can be registered as ITenantContext for non-HTTP scenarios where no middleware is involved:

// In a background job host without HTTP middleware
services.AddSingleton<ITenantContext, TenantScope>();
public class ProcessInvoicesJob(
IServiceScopeFactory scopeFactory,
ILogger<ProcessInvoicesJob> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
var tenants = new[] { "acme", "contoso", "fabrikam" };
foreach (var tenantId in tenants)
{
using var tenantScope = TenantScope.BeginScope(tenantId);
await using var scope = scopeFactory.CreateAsyncScope();
var invoiceService = scope.ServiceProvider.GetRequiredService<IInvoiceService>();
await invoiceService.ProcessPendingInvoicesAsync(ct);
logger.LogInformation("Processed invoices for tenant {TenantId}", tenantId);
}
}
}

ICurrentUser exposes TenantId as a property. This value comes from the identity layer (claims) and is available alongside the multi-tenancy context for authorization and auditing.

The Source Generator detects ITenantEntity on entities and generates:

  • {Entity}.TenantFilter — nested IQueryFilter<T> class (priority 200) that injects ITenantContext and filters entity.TenantId == tenantContext.TenantId.
  • Auto-set on insert — the persistence layer sets TenantId on entity creation.

These filters are applied globally by the query pipeline. No manual filtering is needed.

For the Property entity above, the SG generates:

// Auto-generated by Pragmatic.SourceGenerator
public partial class Property
{
internal sealed class TenantFilter(ITenantContext tenantContext)
: IQueryFilter<Property>
{
public int Priority => 200;
public Expression<Func<Property, bool>> Apply()
=> entity => entity.TenantId == tenantContext.TenantId;
}
}

ITenantContext is consumed by the caching layer for tenant-scoped cache keys, ensuring tenant data isolation in the cache.

FeatureFlagContext has a TenantId property, but bridging with ITenantContext is manual. The application’s IFeatureFlagContextProvider must explicitly copy tenantContext.TenantId into FeatureFlagContext.TenantId to enable per-tenant feature toggles. There is no automatic wiring between the two contexts.

Tenant isolation and data ownership are complementary. An entity can be both ITenantEntity (row-level tenant isolation) and IOwnedEntity (user-level ownership). The DataAccessFilter composes both checks with OR logic.


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 via the builder:

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

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


TenantResolutionMiddleware runs early in the HTTP 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 by the SG-generated host wiring.

The middleware resolves ITenantResolver and MutableTenantContext from the scoped DI container and populates the context with the resolved tenant ID.


MultiTenancyOptions can be configured via the standard options pattern or via builder methods:

PropertyDefaultDescription
DefaultTenantId"default"Tenant ID for single-tenant deployments
TenantHeaderName"X-Tenant-Id"HTTP header name for header-based resolution
TenantClaimType"tenant_id"JWT claim type for claim-based resolution
TenantRouteParameter"tenantId"Route parameter name for route-based resolution
RequireTenantfalseWhether to require tenant resolution (fail if unresolved)

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

{
"Pragmatic": {
"MultiTenancy": {
"DefaultTenantId": "default",
"TenantHeaderName": "X-Tenant-Id",
"RequireTenant": true
}
}
}

AddPragmaticMultiTenancy() registers:

ServiceLifetimeImplementation
MutableTenantContextScopedConcrete class (writable, set by middleware)
ITenantContextScopedDelegates to MutableTenantContext
ITenantResolverDepends on strategySingleTenantResolver (Singleton) or HTTP resolvers (Scoped)

Core registrations (MutableTenantContext, ITenantContext) use TryAdd to avoid duplicates. The resolver registration does not use TryAdd, so UseMultiTenancy() correctly overrides the SG default.


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 {Count} registered resolver(s)All resolvers returned null

When the SG detects Pragmatic.MultiTenancy in the project references (HasMultiTenancy feature flag), it auto-registers services.AddPragmaticMultiTenancy() with single-tenant defaults. Calling app.UseMultiTenancy(...) in Program.cs overrides the default via DI last-registration-wins.


For unit and integration tests, use TenantScope to set the tenant without HTTP infrastructure:

[Fact]
public async Task Query_FiltersToCurrentTenant()
{
using var scope = TenantScope.BeginScope("test-tenant");
var results = await repository.QueryAsync();
results.Should().AllSatisfy(r => r.TenantId.Should().Be("test-tenant"));
}

TenantScope is a static API with no DI dependency, making it suitable for any test scenario.

[Fact]
public async Task Query_DoesNotLeakAcrossTenants()
{
// Insert data for two tenants
using (TenantScope.BeginScope("tenant-a"))
await repository.AddAsync(CreateInvoice("INV-001"));
using (TenantScope.BeginScope("tenant-b"))
await repository.AddAsync(CreateInvoice("INV-002"));
// Query as tenant-a -- should only see tenant-a's data
using (TenantScope.BeginScope("tenant-a"))
{
var results = await repository.QueryAsync();
results.Should().ContainSingle()
.Which.Number.Should().Be("INV-001");
}
}

ProblemSolution
Manual WHERE TenantId = @t on every querySG-generated TenantFilter per ITenantEntity
Forgot to set TenantId on insertAuto-set by persistence layer
Background jobs have no tenant contextTenantScope.BeginScope() with AsyncLocal
Need to switch resolution per environmentCompositeTenantResolver chain with fallbacks
Cache keys leak across tenantsITenantContext scopes cache keys
White-label SaaS needs subdomain routingSubdomainTenantResolver
Admin needs to cross tenant boundariesTenantScope overrides for background operations
Testing requires HTTP infrastructureTenantScope — static API, no DI needed

TypePackageDescription
ITenantContextAbstractionsRead-only access to current tenant
ITenantResolverAbstractionsStrategy interface for tenant resolution
ITenantEntityAbstractionsMarker for tenant-scoped entities
UnresolvedTenantContextAbstractionsSingleton fallback (all null, IsResolved = false)
MutableTenantContextCoreScoped, writable context set by middleware
SingleTenantResolverCoreFixed tenant ID resolver (zero overhead)
CompositeTenantResolverCoreChain-of-responsibility over multiple resolvers
TenantScopeCoreAsyncLocal-based scope for non-HTTP contexts
MultiTenancyOptionsCoreConfiguration options
MultiTenancyBuilderAspNetCoreFluent builder for resolution strategies
TenantResolutionMiddlewareAspNetCoreHTTP middleware that resolves tenant early in pipeline
HeaderTenantResolverAspNetCoreResolves from HTTP header
ClaimTenantResolverAspNetCoreResolves from JWT claim
SubdomainTenantResolverAspNetCoreResolves from subdomain
RouteTenantResolverAspNetCoreResolves from route parameter
PragmaticBuilderMultiTenancyExtensionsAspNetCoreIPragmaticBuilder.UseMultiTenancy()

With ModuleIntegration
Pragmatic.IdentityICurrentUser.TenantId from JWT claims
Pragmatic.Persistence.EFCoreTenantFilter as IQueryFilter<T>, auto-set on insert
Pragmatic.CachingTenant-scoped cache keys
Pragmatic.FeatureFlagsFeatureFlagContext.TenantId (manual bridge)
Pragmatic.ConfigurationTenant-scoped config overrides via IConfigurationStore
Pragmatic.CompositionSG auto-detection via HasMultiTenancy feature flag
  • .NET 10.0+
  • Pragmatic.Abstractions (interfaces)

Part of the Pragmatic.Design ecosystem.