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.
The Problem
Section titled “The Problem”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 everywherepublic 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
TenantIdset 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:latestreturns 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.
The Solution
Section titled “The Solution”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 filteringpublic 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.
Architecture
Section titled “Architecture”The module is split into two packages:
| Package | Target | Depends On | Purpose |
|---|---|---|---|
Pragmatic.MultiTenancy | net10.0 | Pragmatic.Abstractions | Core runtime: context, resolvers, scope, options |
Pragmatic.MultiTenancy.AspNetCore | net10.0 | Core + Microsoft.AspNetCore.App | HTTP resolvers, middleware, builder |
Abstractions (ITenantContext, ITenantResolver, ITenantEntity) live in Pragmatic.Abstractions so any module can depend on them without pulling in the full runtime.
Multi-Tenancy Model
Section titled “Multi-Tenancy Model”Pragmatic.MultiTenancy uses shared-schema, row-level isolation:
- All tenants share the same database and tables.
- Entities that implement
ITenantEntityget an automaticTenantIdcolumn. - The Source Generator produces a
TenantFilter(priority 200) for eachITenantEntity. This filter is registered as anIQueryFilter<T>and applied automatically by the query pipeline, ensuring tenant isolation without manualWHEREclauses. TenantIdis 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.
Quick Start
Section titled “Quick Start”1. Add the Package
Section titled “1. Add the Package”<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());});3. Mark Entities as Tenant-Scoped
Section titled “3. Mark Entities as Tenant-Scoped”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.
4. Access Tenant Context
Section titled “4. Access Tenant Context”Inject ITenantContext anywhere in the pipeline:
public class MyService(ITenantContext tenantContext){ public void DoWork() { if (tenantContext.IsResolved) { var tenantId = tenantContext.TenantId; // tenant-specific logic } }}Resolution Strategies
Section titled “Resolution Strategies”Configure via MultiTenancyBuilder:
| Method | Source | Default Config |
|---|---|---|
UseSingleTenant(id) | Fixed value | "default" |
UseHeader(name) | HTTP header | X-Tenant-Id |
UseClaim(type) | JWT claim | tenant_id |
UseSubdomain() | First subdomain segment | acme.app.com -> acme |
UseRoute(param) | Route parameter | tenantId |
UseResolver<T>() | Custom ITenantResolver | N/A |
Composite Resolution
Section titled “Composite Resolution”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 fallbackStrategy Selection Guide
Section titled “Strategy Selection Guide”| Scenario | Strategy | Why |
|---|---|---|
| Single-tenant deployment | UseSingleTenant() | No overhead, zero configuration |
| API-first, multiple clients | UseHeader() | Client controls tenant in each request |
| JWT-based authentication | UseClaim() | Tenant embedded in token, tamper-proof |
| White-label SaaS | UseSubdomain() | Natural URL separation per tenant |
| Admin APIs managing tenants | UseRoute() | Tenant in URL path for RESTful design |
| Complex logic (API key, DB lookup) | UseResolver<T>() | Full control over resolution |
| Development | UseHeader() + UseSingleTenant() | Header for testing, fallback for dev |
Resolution Flow
Section titled “Resolution Flow”HTTP Request arrives | vTenantResolutionMiddleware | vCompositeTenantResolver | +-- HeaderTenantResolver -> "X-Tenant-Id: acme" -> "acme" | +-- ClaimTenantResolver -> claim "tenant_id" -> "acme" | +-- SubdomainTenantResolver -> "acme.app.com" -> "acme" | +-- SingleTenantResolver -> (always) -> "default" | vMutableTenantContext.TenantId = "acme" | vAll downstream services see ITenantContext.TenantId = "acme"Non-HTTP Contexts
Section titled “Non-HTTP Contexts”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 flowawait 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.
Nesting
Section titled “Nesting”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 as ITenantContext
Section titled “TenantScope as ITenantContext”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 middlewareservices.AddSingleton<ITenantContext, TenantScope>();Background Job Pattern
Section titled “Background Job Pattern”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); } }}Integration Points
Section titled “Integration Points”ICurrentUser.TenantId
Section titled “ICurrentUser.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.
Persistence (Query Filters)
Section titled “Persistence (Query Filters)”The Source Generator detects ITenantEntity on entities and generates:
{Entity}.TenantFilter— nestedIQueryFilter<T>class (priority 200) that injectsITenantContextand filtersentity.TenantId == tenantContext.TenantId.- Auto-set on insert — the persistence layer sets
TenantIdon entity creation.
These filters are applied globally by the query pipeline. No manual filtering is needed.
Generated Filter Example
Section titled “Generated Filter Example”For the Property entity above, the SG generates:
// Auto-generated by Pragmatic.SourceGeneratorpublic 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; }}Caching
Section titled “Caching”ITenantContext is consumed by the caching layer for tenant-scoped cache keys, ensuring tenant data isolation in the cache.
Feature Flags
Section titled “Feature Flags”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.
Data Ownership
Section titled “Data Ownership”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.
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 via the builder:
app.UseMultiTenancy(mt => mt.UseResolver<ApiKeyTenantResolver>());Custom resolvers participate in the composite chain like built-in ones.
Middleware Ordering
Section titled “Middleware Ordering”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.
Configuration Options
Section titled “Configuration Options”MultiTenancyOptions can be configured via the standard options pattern or via builder methods:
| Property | Default | Description |
|---|---|---|
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 |
RequireTenant | false | Whether 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 } }}DI Registrations
Section titled “DI Registrations”AddPragmaticMultiTenancy() registers:
| Service | Lifetime | Implementation |
|---|---|---|
MutableTenantContext | Scoped | Concrete class (writable, set by middleware) |
ITenantContext | Scoped | Delegates to MutableTenantContext |
ITenantResolver | Depends on strategy | SingleTenantResolver (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.
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 {Count} registered resolver(s) | All resolvers returned null |
SG Auto-Registration
Section titled “SG Auto-Registration”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.
Testing
Section titled “Testing”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.
Cross-Tenant Test
Section titled “Cross-Tenant Test”[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"); }}Feature Summary
Section titled “Feature Summary”| Problem | Solution |
|---|---|
Manual WHERE TenantId = @t on every query | SG-generated TenantFilter per ITenantEntity |
| Forgot to set TenantId on insert | Auto-set by persistence layer |
| Background jobs have no tenant context | TenantScope.BeginScope() with AsyncLocal |
| Need to switch resolution per environment | CompositeTenantResolver chain with fallbacks |
| Cache keys leak across tenants | ITenantContext scopes cache keys |
| White-label SaaS needs subdomain routing | SubdomainTenantResolver |
| Admin needs to cross tenant boundaries | TenantScope overrides for background operations |
| Testing requires HTTP infrastructure | TenantScope — static API, no DI needed |
Key Types
Section titled “Key Types”| Type | Package | Description |
|---|---|---|
ITenantContext | Abstractions | Read-only access to current tenant |
ITenantResolver | Abstractions | Strategy interface for tenant resolution |
ITenantEntity | Abstractions | Marker for tenant-scoped entities |
UnresolvedTenantContext | Abstractions | Singleton fallback (all null, IsResolved = false) |
MutableTenantContext | Core | Scoped, writable context set by middleware |
SingleTenantResolver | Core | Fixed tenant ID resolver (zero overhead) |
CompositeTenantResolver | Core | Chain-of-responsibility over multiple resolvers |
TenantScope | Core | AsyncLocal-based scope for non-HTTP contexts |
MultiTenancyOptions | Core | Configuration options |
MultiTenancyBuilder | AspNetCore | Fluent builder for resolution strategies |
TenantResolutionMiddleware | AspNetCore | HTTP middleware that resolves tenant early in pipeline |
HeaderTenantResolver | AspNetCore | Resolves from HTTP header |
ClaimTenantResolver | AspNetCore | Resolves from JWT claim |
SubdomainTenantResolver | AspNetCore | Resolves from subdomain |
RouteTenantResolver | AspNetCore | Resolves from route parameter |
PragmaticBuilderMultiTenancyExtensions | AspNetCore | IPragmaticBuilder.UseMultiTenancy() |
Cross-Module Integration
Section titled “Cross-Module Integration”| With Module | Integration |
|---|---|
| Pragmatic.Identity | ICurrentUser.TenantId from JWT claims |
| Pragmatic.Persistence.EFCore | TenantFilter as IQueryFilter<T>, auto-set on insert |
| Pragmatic.Caching | Tenant-scoped cache keys |
| Pragmatic.FeatureFlags | FeatureFlagContext.TenantId (manual bridge) |
| Pragmatic.Configuration | Tenant-scoped config overrides via IConfigurationStore |
| Pragmatic.Composition | SG auto-detection via HasMultiTenancy feature flag |
Further Reading
Section titled “Further Reading”- Getting Started — setup from zero
- Concepts — architecture, resolution strategies, and core design
- Tenant Resolution — strategies, middleware, and configuration
- Common Mistakes — avoid the most frequent pitfalls
- Troubleshooting — problem/solution guide
Requirements
Section titled “Requirements”- .NET 10.0+
Pragmatic.Abstractions(interfaces)
License
Section titled “License”Part of the Pragmatic.Design ecosystem.