Common Mistakes
These are the most common issues developers encounter when using Pragmatic.MultiTenancy. Each section shows the wrong approach, the correct approach, and explains why.
1. Forgetting ITenantEntity on Entities That Need Isolation
Section titled “1. Forgetting ITenantEntity on Entities That Need Isolation”Wrong:
[Entity<Guid>][BelongsTo<CatalogBoundary>]public partial class Invoice : IEntity<Guid>{ public string Number { get; private set; } = ""; public decimal Amount { get; private set; } public string TenantId { get; set; } = ""; // Property exists but no interface}Right:
[Entity<Guid>][BelongsTo<CatalogBoundary>]public partial class Invoice : IEntity<Guid>, ITenantEntity{ public string Number { get; private set; } = ""; public decimal Amount { get; private set; } public string TenantId { get; set; } = "";}Why: The Source Generator only generates TenantFilter for entities that implement the ITenantEntity interface. Having a TenantId property without the interface means no automatic filtering — queries will return data from all tenants. The interface is the trigger, not the property name.
2. Manually Filtering by TenantId in Queries
Section titled “2. Manually Filtering by TenantId in Queries”Wrong:
public class InvoiceService( IReadRepository<Invoice, Guid> invoices, ITenantContext tenantContext){ public async Task<List<Invoice>> GetInvoicesAsync(CancellationToken ct) { return await invoices .Where(i => i.TenantId == tenantContext.TenantId) // redundant! .ToListAsync(ct); }}Right:
public class InvoiceService(IReadRepository<Invoice, Guid> invoices){ public async Task<List<Invoice>> GetInvoicesAsync(CancellationToken ct) { // TenantFilter is applied automatically by the query pipeline return await invoices.QueryAsync(ct); }}Why: When Invoice implements ITenantEntity, the SG generates a TenantFilter that is applied by the query pipeline to every query. Adding a manual filter results in a redundant WHERE clause. Worse, if you only sometimes remember to add the manual filter, you get inconsistent behavior. Trust the framework — the filter is always applied.
3. Using the Wrong Resolution Strategy for the Context
Section titled “3. Using the Wrong Resolution Strategy for the Context”Wrong:
// Using header-based resolution for a user-facing SaaS appapp.UseMultiTenancy(mt => mt.UseHeader());
// Problem: end users don't control HTTP headers in the browser.// A malicious user could set X-Tenant-Id to another tenant's ID.Right:
// Use claim-based resolution for user-facing apps -- tenant is in the JWTapp.UseMultiTenancy(mt => mt.UseClaim());
// Or subdomain-based for vanity URLsapp.UseMultiTenancy(mt => mt.UseSubdomain());Why: Header-based resolution is appropriate for B2B APIs and service-to-service communication where the caller is trusted. For user-facing applications, the tenant should come from a cryptographically verified source (JWT claim) or from the URL (subdomain/route). The choice of strategy is a security decision.
| Scenario | Recommended Strategy |
|---|---|
| B2B API, trusted callers | UseHeader() |
| User-facing SaaS with JWT | UseClaim() |
| Vanity subdomains | UseSubdomain() |
| Tenant in URL path | UseRoute() |
| Development/testing | UseHeader() or UseSingleTenant() |
4. Not Setting Up TenantScope for Background Jobs
Section titled “4. Not Setting Up TenantScope for Background Jobs”Wrong:
public class MonthlyBillingJob(IServiceProvider services){ public async Task RunAsync(List<string> tenantIds) { foreach (var tenantId in tenantIds) { // No tenant context! ITenantContext.IsResolved is false. // TenantFilter returns nothing (or worse, all data). using var scope = services.CreateScope(); var invoices = scope.ServiceProvider.GetRequiredService<IInvoiceService>(); await invoices.GenerateMonthlyBillsAsync(); } }}Right:
public class MonthlyBillingJob(IServiceProvider services){ public async Task RunAsync(List<string> tenantIds) { foreach (var tenantId in tenantIds) { using var tenantScope = TenantScope.BeginScope(tenantId); using var diScope = services.CreateScope(); var invoices = diScope.ServiceProvider.GetRequiredService<IInvoiceService>(); await invoices.GenerateMonthlyBillsAsync(); } }}Why: Outside HTTP requests, there is no middleware to resolve the tenant. TenantScope.BeginScope sets the tenant for the current async flow via AsyncLocal<T>. Without it, ITenantContext.IsResolved is false and TenantId is null, which means the TenantFilter will not match any rows (or all rows, depending on the null-handling semantics).
5. Hardcoding Tenant IDs in Service Logic
Section titled “5. Hardcoding Tenant IDs in Service Logic”Wrong:
public class ReportService(IDbContext db){ public async Task<Report> GetReportAsync(string tenantId, CancellationToken ct) { // Hardcoded tenant filtering -- bypasses the framework var data = await db.Invoices .Where(i => i.TenantId == tenantId) .GroupBy(i => i.Month) .ToListAsync(ct);
return new Report(data); }}Right:
public class ReportService(IReadRepository<Invoice, Guid> invoices){ public async Task<Report> GetReportAsync(CancellationToken ct) { // Tenant filtering is automatic -- no tenantId parameter needed var data = await invoices.QueryAsync(ct); // data is already filtered to the current tenant
return new Report(GroupByMonth(data)); }}Why: Passing tenantId as a parameter means every caller must know the current tenant and pass it correctly. This is error-prone and defeats the purpose of ambient tenant context. Use ITenantContext (injected automatically) and let the query pipeline handle filtering. If you need the tenant ID for business logic (not filtering), inject ITenantContext directly.
6. Confusing ITenantContext with ICurrentUser.TenantId
Section titled “6. Confusing ITenantContext with ICurrentUser.TenantId”Wrong:
public class TenantAwareService(ICurrentUser currentUser){ public void DoWork() { // Using ICurrentUser.TenantId for tenant filtering context var tenantId = currentUser.TenantId; // Problem: ICurrentUser.TenantId comes from JWT claims // ITenantContext.TenantId comes from the resolution strategy // They may be different (e.g., admin viewing another tenant's data) }}Right:
public class TenantAwareService(ITenantContext tenantContext){ public void DoWork() { // ITenantContext is the canonical source for "which tenant's data to access" var tenantId = tenantContext.TenantId; }}Why: ITenantContext and ICurrentUser.TenantId serve different purposes. ITenantContext answers “which tenant’s data should this request access” — it comes from the resolution strategy (header, claim, subdomain, etc.). ICurrentUser.TenantId answers “which tenant does this user belong to” — it always comes from the JWT. In most cases they match, but they can differ (e.g., admin impersonation, cross-tenant operations).
| Context | Source | Use For |
|---|---|---|
ITenantContext.TenantId | Resolution strategy | Data filtering, scoping |
ICurrentUser.TenantId | JWT claim | Authorization, audit trail |
7. Assuming FeatureFlagContext.TenantId Is Auto-Populated
Section titled “7. Assuming FeatureFlagContext.TenantId Is Auto-Populated”Wrong:
public class FeatureCheckService(IFeatureFlagStore flags){ public async Task<bool> IsNewCheckoutEnabled(CancellationToken ct) { // Assuming TenantId is automatically populated var context = new FeatureFlagContext(); return await flags.IsEnabledAsync("new-checkout", context, ct); // context.TenantId is null -- tenant rules won't match }}Right:
public class FeatureCheckService(IFeatureFlagStore flags, ITenantContext tenantContext){ public async Task<bool> IsNewCheckoutEnabled(CancellationToken ct) { var context = new FeatureFlagContext { TenantId = tenantContext.TenantId // explicit bridging }; return await flags.IsEnabledAsync("new-checkout", context, ct); }}Or better — implement IFeatureFlagContextProvider:
public class HttpFeatureFlagContextProvider( ITenantContext tenantContext, ICurrentUser currentUser, IHostEnvironment hostEnv) : IFeatureFlagContextProvider{ public Task<FeatureFlagContext> GetContextAsync(CancellationToken ct = default) { return Task.FromResult(new FeatureFlagContext { TenantId = tenantContext.TenantId, UserId = currentUser.UserId, Environment = hostEnv.EnvironmentName }); }}Why: There is no automatic wiring between ITenantContext and FeatureFlagContext.TenantId. These are separate systems. You must explicitly copy the tenant ID into the feature flag context. The IFeatureFlagContextProvider pattern centralizes this bridging so you don’t repeat it in every feature flag check.
8. Not Using TryAdd Awareness When Registering Resolvers
Section titled “8. Not Using TryAdd Awareness When Registering Resolvers”Wrong:
// In Program.csservices.AddSingleton<ITenantResolver>(new SingleTenantResolver("manual-default"));
// Later in the same file or a startup stepapp.UseMultiTenancy(mt => mt.UseHeader());
// The UseHeader() registration replaces SingleTenantResolver because// AddScoped<ITenantResolver, HeaderTenantResolver>() overwrites the singleton.Also wrong:
// Expecting TryAdd to keep the first registrationservices.TryAddSingleton<ITenantResolver>(new SingleTenantResolver("my-default"));app.UseMultiTenancy(mt => mt.UseClaim());// UseClaim() uses AddScoped (not TryAdd) -- it always replaces.Right:
// Use the builder fluently -- one place, one strategyapp.UseMultiTenancy(mt => mt.UseClaim());
// Or chain for fallbackapp.UseMultiTenancy(mt => mt .UseClaim() .UseSingleTenant("fallback"));Why: The builder methods use AddScoped (not TryAdd) for resolver registration, so they always override previous registrations. The MutableTenantContext and ITenantContext use TryAdd (registered once). The design intention is: the SG registers a default, and UseMultiTenancy() overrides it. Use the builder as the single source of truth for your resolution strategy.
9. Expecting RequireTenant to Enforce Resolution
Section titled “9. Expecting RequireTenant to Enforce Resolution”Wrong:
services.Configure<MultiTenancyOptions>(o => o.RequireTenant = true);// Expecting unresolved tenants to cause 401/403 automaticallyRight:
// RequireTenant is reserved for future framework-level enforcement.// For now, use a pre-processor:public class TenantValidationPreProcessor( ITenantContext tenantContext) : 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()); }}Why: MultiTenancyOptions.RequireTenant exists in the options class but is not currently consumed by any runtime code. It is reserved for future framework-level enforcement. To enforce that every request has a resolved tenant, use an IEndpointPreProcessor as shown above. This gives you full control over the error response and can be applied selectively to specific endpoint groups.
10. Not Testing Tenant Isolation
Section titled “10. Not Testing Tenant Isolation”Wrong:
[Fact]public async Task CreateInvoice_SavesSuccessfully(){ // Testing without tenant context -- TenantFilter behavior unknown var invoice = Invoice.Create("INV-001", 1500.00m); await repository.CreateAsync(invoice);
var result = await repository.GetByIdAsync(invoice.Id); result.Should().NotBeNull();}Right:
[Fact]public async Task CreateInvoice_IsolatedByTenant(){ // Set tenant A, create invoice using (TenantScope.BeginScope("tenant-a")) { var invoice = Invoice.Create("INV-001", 1500.00m); await repository.CreateAsync(invoice); }
// Verify tenant B cannot see it using (TenantScope.BeginScope("tenant-b")) { var results = await repository.QueryAsync(); results.Should().BeEmpty(); }
// Verify tenant A can see it using (TenantScope.BeginScope("tenant-a")) { var results = await repository.QueryAsync(); results.Should().ContainSingle(i => i.Number == "INV-001"); }}Why: Multi-tenancy is a security boundary. If you don’t test that tenants are isolated from each other, you won’t catch cross-tenant data leaks. Always test that data created by one tenant is invisible to another tenant. TenantScope makes this straightforward — no HTTP infrastructure needed.