Skip to content

Getting Started with Pragmatic.MultiTenancy

This guide walks through adding multi-tenancy to a Pragmatic.Design application from scratch.

  • A Pragmatic.Design host application using PragmaticApp.RunAsync
  • Pragmatic.Persistence (for automatic tenant query filters)

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.

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 default
app.UseMultiTenancy(mt => mt
.UseHeader()
.UseClaim()
.UseSingleTenant("fallback-tenant"));

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 — an IQueryFilter<Invoice> that adds WHERE TenantId = @currentTenant to 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.

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 test
using 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.

When the SG detects Pragmatic.MultiTenancy in your project:

RegistrationLifetimeDescription
MutableTenantContextScopedWritable context, set by middleware
ITenantContextScopedRead-only interface, delegates to MutableTenantContext
ITenantResolverVariesSingleTenantResolver (Singleton) by default, or the configured strategy
{Entity}.TenantFilterScopedOne 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.

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)