Getting Started with Pragmatic.MultiTenancy
This guide walks through adding multi-tenancy to a Pragmatic.Design application from scratch.
Prerequisites
Section titled “Prerequisites”- A Pragmatic.Design host application using
PragmaticApp.RunAsync Pragmatic.Persistence(for automatic tenant query filters)
Step 1: Add the Package
Section titled “Step 1: Add the Package”Add the ASP.NET Core bridge package (it pulls in the core package transitively):
<PackageReference Include="Pragmatic.MultiTenancy.AspNetCore" />Once the package is referenced, the Source Generator detects it via FeatureDetector (HasMultiTenancy flag) and auto-registers services.AddPragmaticMultiTenancy() with single-tenant defaults. Single-tenant apps need no further configuration.
Step 2: Choose a Resolution Strategy
Section titled “Step 2: Choose a Resolution Strategy”For multi-tenant applications, configure a resolution strategy in Program.cs:
await PragmaticApp.RunAsync(args, app =>{ // Resolve tenant from the X-Tenant-Id HTTP header app.UseMultiTenancy(mt => mt.UseHeader());});This overrides the SG-registered default (single-tenant) via DI last-registration-wins.
Available built-in strategies:
// HTTP header (default: X-Tenant-Id)app.UseMultiTenancy(mt => mt.UseHeader());
// JWT claim (default: tenant_id)app.UseMultiTenancy(mt => mt.UseClaim());
// Subdomain (acme.app.com -> acme)app.UseMultiTenancy(mt => mt.UseSubdomain());
// Route parameter (default: {tenantId})app.UseMultiTenancy(mt => mt.UseRoute());
// Chain: try header, fall back to claim, then fixed defaultapp.UseMultiTenancy(mt => mt .UseHeader() .UseClaim() .UseSingleTenant("fallback-tenant"));Step 3: Mark Entities as Tenant-Scoped
Section titled “Step 3: Mark Entities as Tenant-Scoped”Add ITenantEntity to entities that need row-level tenant isolation:
using Pragmatic.MultiTenancy;using Pragmatic.Persistence.Entity;
[Entity<Guid>][BelongsTo<MyBoundary>]public partial class Invoice : IEntity<Guid>, ITenantEntity{ public string Number { get; private set; } = ""; public decimal Amount { get; private set; }
// ITenantEntity implementation // Auto-set on insert, auto-filtered on all reads public string TenantId { get; set; } = "";}The Source Generator will produce:
Invoice.TenantFilter— anIQueryFilter<Invoice>that addsWHERE TenantId = @currentTenantto every query automatically.- The filter has priority 200 (runs after SoftDelete filters at priority 100).
No manual WHERE clauses or global query filter configuration needed.
Step 4: Access Tenant Context in Services
Section titled “Step 4: Access Tenant Context in Services”Inject ITenantContext to read the current tenant anywhere in the pipeline:
public class TenantAwareService(ITenantContext tenantContext){ public string GetCurrentTenant() { if (!tenantContext.IsResolved) throw new InvalidOperationException("No tenant resolved");
return tenantContext.TenantId!; }}ITenantContext is scoped per request. In HTTP contexts, the TenantResolutionMiddleware populates it before routing. In non-HTTP contexts, use TenantScope:
// Background job, seed script, or testusing var scope = TenantScope.BeginScope("tenant-42");await tenantAwareService.DoWorkAsync();Step 5: Optional — Enforce Tenant Resolution
Section titled “Step 5: Optional — Enforce Tenant Resolution”If your application requires that every request has a resolved tenant, you can add validation. The Showcase app demonstrates this with an IEndpointPreProcessor:
public class TenantValidationPreProcessor( ITenantContext tenantContext, ILogger<TenantValidationPreProcessor> logger) : IEndpointPreProcessor{ public ValueTask<PreProcessorResult> ProcessAsync( IEndpointContext context, CancellationToken ct = default) { if (!tenantContext.IsResolved) { return ValueTask.FromResult( PreProcessorResult.Fail(UnauthorizedError.Create("TenantNotResolved"))); }
return ValueTask.FromResult(PreProcessorResult.Continue()); }}Note: MultiTenancyOptions has a RequireTenant property, but it is not currently consumed by any runtime code — it is reserved for future framework-level enforcement. For now, use a pre-processor like the example above to enforce tenant resolution.
What Gets Auto-Registered
Section titled “What Gets Auto-Registered”When the SG detects Pragmatic.MultiTenancy in your project:
| Registration | Lifetime | Description |
|---|---|---|
MutableTenantContext | Scoped | Writable context, set by middleware |
ITenantContext | Scoped | Read-only interface, delegates to MutableTenantContext |
ITenantResolver | Varies | SingleTenantResolver (Singleton) by default, or the configured strategy |
{Entity}.TenantFilter | Scoped | One per ITenantEntity, auto-registered as IQueryFilter<T> |
When you call app.UseMultiTenancy(mt => ...), the builder replaces the default resolver registration with your chosen strategy. The MutableTenantContext and ITenantContext registrations use TryAdd so they are only registered once.
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 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)