Secure, high-performance, Native AOT-compatible, enterprise-grade multi-tenancy ecosystem and 4-layer Defense-in-Depth isolation for modern .NET.
EricksonLopez.MultiTenancy is a foundational, Native AOT-first multi-tenancy ecosystem engineered for .NET 8.0 (LTS), .NET 9.0 (STS), and .NET 10.0 applications built with Clean Architecture, Domain-Driven Design (DDD), and relational persistence engines (PostgreSQL, Microsoft SQL Server, MySQL, MariaDB, Oracle, and SQLite).
The architecture is founded upon an absolute, non-negotiable security invariant:
A single defective layer must not be able to destroy tenant isolation (Defense-in-Depth).
By coupling explicit application parameterization, scoped write-once accessors, and transaction-scoped database session contexts (SET LOCAL for PostgreSQL Row Level Security, sp_set_session_context for SQL Server), the ecosystem eliminates ambient state leakage across recycled connection pools, rejects brittle dynamic SQL string rewriting, enforces compile-time Roslyn diagnostic rules, and guarantees zero heap allocations on identifier comparisons and resolution hot paths.
- What Problem It Solves
- Key Features
- Ecosystem
- Documentation
- Installation
- Quick Start
- Core Use Cases
- 1. Clean Architecture & CQRS Query Handlers
- 2. Multi-Strategy Resolution with Fail-Closed Conflict Detection
- 3. Per-Tenant Configuration & Feature Options
- 4. Background Job & Message Queue Consumers
- 5. Per-Tenant Authentication Schemes & Dynamic Cookies
- 6. Database-per-Tenant Dynamic Connection Routing
- Configuration & Integrations
- Testing & Quality
- Performance Benchmarks
- Compatibility & Technical Matrix
- Architecture & Design Principles
- Best Practices & Anti-Patterns
- Troubleshooting & Common Pitfalls
- Part of the Ecosystem
- Contributing
- License
- The Fragile Illusion of Application-Only Filtering:
Legacy libraries rely exclusively on runtime AST/regex SQL string rewriting or Entity Framework Core Global Query Filters (
HasQueryFilter). If an engineer executes a raw Dapper query, uses a subquery, joins an un-mapped view, or invokes a native stored procedure, the filter is omitted and tenant isolation collapses silently. - Connection Pool Contamination & Session Bleeding:
Setting session-level database state (
SET app.current_tenant = 'tenant-a') binds state to the physical connection. When ADO.NET returns the connection to the connection pool, subsequent requests for Tenant B reusing that connection inherit Tenant A's session context, triggering catastrophic cross-tenant data leaks. - Ambient State Bleeding & Captive Singletons:
Storing mutable tenant context in static
AsyncLocal<T>slots causes ambient context leakage across un-awaited tasks and background worker threads. Furthermore, resolving scoped tenant accessors inside Singleton-lifetime services creates captive dependencies, permanently freezing the tenant context of the first request that hit the server. - Tenant Identifier Spoofing via Ambiguous Resolution:
Allowing unauthenticated HTTP headers (
X-Tenant-ID) to silently override cryptographically validated JWT claims allows malicious actors to escalate privileges and access unauthorized tenant partitions. - Memory Allocation Overhead & Reflection Bottlenecks:
Representing tenant identifiers as generic heap strings (
string TenantId) causes continuous heap allocations, garbage collection pressure, and hash collisions. Relying on heavy runtime reflection prevents modern compilation targets such as Native AOT and assembly trimming.
- π‘οΈ 4-Layer Defense-in-Depth: Application-level parameterization (
WHERE tenant_id = @TenantId), scoped accessor validation, transaction-scoped database session binding, and database-level Row Level Security (RLS) operate collaboratively so no single code defect can breach tenant boundaries. - π Transaction-Scoped Database Isolation (
SET LOCAL): Database context variables are bound strictly to the transaction lifecycle viaSET LOCAL(PostgreSQL) orsp_set_session_context(SQL Server). When a transaction completes (COMMITorROLLBACK), the database automatically purges the variable, returning clean connections to the pool. - π§± Immutable, Zero-Allocation Struct
TenantId: A 128-bitGuid-backed readonly record struct implementingIEquatable<TenantId>andIComparable<TenantId>with safe stack span parsing (TenantId.TryCreate) and zero heap boxing. - π¦ Fail-Closed Resolution Precedence (ADR-008): Cryptographically verified JWT claims strictly supersede client-controlled data. If multiple resolution strategies yield conflicting tenant identifiers, the pipeline immediately fails closed β emitting HTTP 409 Conflict (when
WriteProblemDetailsOnConflict = true) or throwingTenantResolutionConflictException(whenfalse). - π΅οΈ Compile-Time Roslyn Analyzers: Analyzers
ELMT001,ELMT002, andELMT003intercept static context leaks, captive singleton injections, and un-scoped Dapper queries directly during compilation. - β‘ 100% Native AOT & Trimming Compliant: Zero runtime reflection, zero dynamic code generation (
IL.Emit), and explicit type registrations ensure full compatibility with ahead-of-time compilation.
- π‘οΈ 4-Layer Defense-in-Depth Architecture: Guarantees isolation even if application code fails to apply a WHERE filter.
- π Zero-Allocation
TenantIdPrimitives: 128-bit stack struct with explicit UTF-8 JSON converters and span parsing. - π Transaction-Scoped RLS Integration: Native adapters for PostgreSQL RLS, SQL Server
SESSION_CONTEXT, MySQL/MariaDB session variables, Oracle VPD, and SQLite DB-per-tenant. - β‘ Native AOT & Trimming Ready: Pre-configured with
<IsAotCompatible>true</IsAotCompatible>and verified via smoke test suites. - π΅οΈ Dedicated Roslyn Static Analyzers: Built-in compiler rules (
ELMT001-ELMT003) enforcing lifecycle and DI safety. - π¦ Fail-Closed Strategy Conflict Resolution: Automatic detection and rejection of conflicting tenant resolution vectors (ADR-008).
- π Isolated Background Scope Factory:
ITenantScopeFactorycreates clean, isolated DI service scopes for non-HTTP background workers and message queue consumers. - πͺ Per-Tenant Authentication & Options: Dynamic cookie event validation, per-tenant options caching, and scheme routing.
- π Native OpenTelemetry Instrumentation: Distributed tracing (
TenantActivitySource), metrics (TenantMetrics), and W3C Baggage propagation. - π§ͺ Comprehensive Test Doubles & Harnesses: Built-in
FakeTenantStore,FakeTenantResolutionStrategy, andTenantContextBuilderfor frictionless unit and integration testing.
| Package | Version | Description |
|---|---|---|
EricksonLopez.MultiTenancy.Abstractions |
Foundational contracts: TenantId, ITenantInfo, ITenantContext, ITenantResolver, ITenantScope, and TenantErrors. |
|
EricksonLopez.MultiTenancy |
Core engine: ScopedTenantContextAccessor, DefaultTenantScopeFactory, InMemoryTenantStore, and CachedTenantStore. |
|
EricksonLopez.MultiTenancy.Analyzers |
Roslyn analyzers: static field leaks (ELMT001), singleton captivity (ELMT002), un-scoped Dapper queries (ELMT003). |
|
EricksonLopez.MultiTenancy.AspNetCore |
ASP.NET Core resolution middleware, JWT/Host/Route/Header strategies, .RequireTenant() endpoint filters. |
|
EricksonLopez.MultiTenancy.Authentication |
Per-tenant authentication schemes, cookie validation events, dynamic scheme routing. | |
EricksonLopez.MultiTenancy.Configuration |
IConfiguration and IOptionsMonitor-backed tenant store for file-based configuration. |
|
EricksonLopez.MultiTenancy.Dapper |
Dapper parameter builders and query parameter helpers (WithTenant, CreateTenantParameters). |
|
EricksonLopez.MultiTenancy.PostgreSql |
PostgreSQL Row Level Security (RLS) enforcement via transaction-scoped SET LOCAL + PostgreSqlTenantStore. |
|
EricksonLopez.MultiTenancy.SqlServer |
SQL Server SESSION_CONTEXT management via sp_set_session_context and security policies. |
|
EricksonLopez.MultiTenancy.MySql |
MySQL session variable isolation (@app_tenant_id) and connection scoping. |
|
EricksonLopez.MultiTenancy.MariaDb |
MariaDB session variable isolation (@app_tenant_id) and connection scoping. |
|
EricksonLopez.MultiTenancy.Oracle |
Oracle Virtual Private Database (VPD) and DBMS_SESSION.SET_IDENTIFIER integration. |
|
EricksonLopez.MultiTenancy.Sqlite |
SQLite database-per-tenant (ISqliteTenantConnectionFactory) and temp table session context. |
|
EricksonLopez.MultiTenancy.OpenTelemetry |
Distributed tracing (TenantActivitySource), W3C Baggage propagation, metrics (TenantMetrics). |
|
EricksonLopez.MultiTenancy.Testing |
Test doubles: FakeTenantStore, FakeTenantResolutionStrategy, TenantContextBuilder, FakeDbInfrastructure. |
π Official Documentation Hub: https://github.com/ericksonlopezf/dotnet-multitenancy/tree/main/docs
| Level | Topic | Description |
|---|---|---|
| Level 00 | Foundations & Architecture | Core architectural pillars, TenantId struct invariants, and zero context leakage |
| Level 01 | Getting Started & Registration | Minimal DI setup, in-memory store seeding, and core pipeline initialization |
| Level 02 | Resolution Strategies & Precedence | Claims, HostName, Route, BasePath, Header strategies, and fail-closed conflict detection |
| Level 03 | Database-per-Tenant Pattern | Dynamic SQLite connection factories and isolated database file routing |
| Level 04 | Shared Database & Row-Level Security | PostgreSQL SET LOCAL, SQL Server SESSION_CONTEXT, and MySQL/MariaDB isolation |
| Level 05 | ASP.NET Core & Endpoint Security | Middleware ordering, .RequireTenant() endpoint filters, and .AllowAnonymousTenant() |
| Level 06 | Per-Tenant Authentication | Tenant cookie authentication events, dynamic schemes, and cross-subdomain safety |
| Level 07 | Native AOT & Zero-Allocation | Ahead-of-time compilation, trimming annotations, and stack-allocated span parsing |
| Level 08 | Telemetry, Observability & Testing | OpenTelemetry ActivitySource, metrics, W3C Baggage, and test doubles harness |
π Runnable showcase project implementation: Showcase Sample Application
- System Architecture & Invariants β Comprehensive architectural blueprint, DI lifetimes, package layering, and safety invariants.
- System Overview & Blueprint β End-to-end request flow diagram and component interaction topology.
- Master Feature Matrix β Module-by-module capability inventory, Native AOT verification, and dialect matrices.
- Strategic Matrices & Roadmap β Detailed capability matrices, release gates checklist, and engineering roadmap.
- Public API Inventory β Complete inventory of public types and contracts across all 15 packages.
- Architectural Decision Records (ADRs) β Formal ADR-001 through ADR-012 plus Discard Records (ADR-D01 to ADR-D04).
- Roslyn Diagnostic Rules (
docs/rules/) β Technical rule specifications forELMT001,ELMT002, andELMT003. - Benchmark Plan & Budgets β Performance budgets, allocation limits, and benchmark suite architecture.
- Benchmark Results & Evidence β Competitive benchmarks vs legacy reflection and ambient models.
- Quality Gates & DevSecOps β Automated quality gates, SonarCloud, Stryker, and Native AOT policies.
- Testing & Mutation Audit Report β Test inventory, mutation scores breakdown (100% kill rate), and ArchUnit rules.
- Security Threat Model (STRIDE) β Threat model, attack vectors, precedence guarantees, and mitigations.
- PostgreSQL Row Level Security (RLS) β Transaction-scoped
SET LOCALintegration,FORCE ROW LEVEL SECURITY, and restrictive policies. - Database Dialects Matrix β Multi-database comparison across PostgreSQL, SQL Server, MySQL, MariaDB, Oracle, and SQLite.
- Background Processing Guide β Scoping background jobs and message queue handlers with
ITenantScopeFactory. - Per-Tenant Authentication β Per-tenant authentication schemes, options cache, and cookie security events.
- Performance & Native AOT Guide β Zero-allocation struct designs, span parsing, and trimming compatibility.
- Testing Strategy & Quality Gates β Test suite architecture, 100% coverage policy, and Stryker mutation testing gates.
- CI/CD & DevSecOps Pipelines β Pipeline architecture, Sigstore SLSA provenance, and NuGet OIDC release automation.
- Best Practices & Roslyn Analyzers β Coding standards and compiler diagnostic rules (
ELMT001-ELMT003). - Cookbook & Engineering Recipes β 12 production-ready engineering recipes for enterprise scenarios.
- Troubleshooting & Diagnostics β Detailed remediation for resolution conflicts, RLS issues, and captive dependencies.
- Feature Status Matrix β Module-by-module feature status, central package management, and framework support.
- Architectural FAQ β Frequently asked architectural and security questions.
- Migration Guide β Step-by-step migration guide from legacy multi-tenancy packages.
- Competitive Functional Parity Audit β In-depth architectural and functional comparison vs alternative libraries.
# Core abstractions, TenantId struct, and contracts (Layer 0)
dotnet add package EricksonLopez.MultiTenancy.Abstractions
# Core engine, scoped accessors, and scope factory (Layer 1)
dotnet add package EricksonLopez.MultiTenancy
# Compile-time Roslyn static analyzers (Layer 2)
dotnet add package EricksonLopez.MultiTenancy.Analyzers# ASP.NET Core resolution middleware, strategies, and endpoint filters (Layer 3)
dotnet add package EricksonLopez.MultiTenancy.AspNetCore
# Per-tenant authentication schemes and dynamic cookie events (Layer 3)
dotnet add package EricksonLopez.MultiTenancy.Authentication
# IConfiguration and IOptionsMonitor-backed tenant store (Layer 3)
dotnet add package EricksonLopez.MultiTenancy.Configuration# Dapper parameter builders and query helpers (Layer 4)
dotnet add package EricksonLopez.MultiTenancy.Dapper
# Choose your database dialect engine adapter (Layer 5):
dotnet add package EricksonLopez.MultiTenancy.PostgreSql
dotnet add package EricksonLopez.MultiTenancy.SqlServer
dotnet add package EricksonLopez.MultiTenancy.MySql
dotnet add package EricksonLopez.MultiTenancy.MariaDb
dotnet add package EricksonLopez.MultiTenancy.Oracle
dotnet add package EricksonLopez.MultiTenancy.Sqlite# OpenTelemetry distributed tracing and metrics (Layer 6)
dotnet add package EricksonLopez.MultiTenancy.OpenTelemetry
# Testing doubles and unit testing harnesses (Layer 7)
dotnet add package EricksonLopez.MultiTenancy.Testingusing EricksonLopez.MultiTenancy;
// 1. Immutable 128-bit readonly record struct backed by Guid
var tenantId = TenantId.Create("11111111-1111-1111-1111-111111111111");
// 2. Safe stack-allocated span parsing (Zero heap allocations)
if (TenantId.TryCreate("22222222-2222-2222-2222-222222222222", out var parsedId))
{
Console.WriteLine($"Successfully parsed: {parsedId}");
}
// 3. Domain tenant metadata entity
var tenantInfo = new TenantInfo(
id: tenantId,
name: "acme-corp",
isActive: true);using EricksonLopez.MultiTenancy;
using EricksonLopez.MultiTenancy.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// 1. Register core multi-tenancy engine
builder.Services.AddMultiTenancy();
// 2. Register ASP.NET Core resolution pipeline (JWT claims enabled by default)
builder.Services.AddAspNetCoreMultiTenancy();
// 3. Register additional resolution strategies (Precedence: Claims > Host > Route > Header)
builder.Services.AddHostNameTenantStrategy();
builder.Services.AddRouteTenantStrategy("tenantId");
// 4. Seed an in-memory tenant store for local development
builder.Services.AddInMemoryTenantStore<TenantInfo>(store =>
{
store.AddOrUpdate(new TenantInfo(
id: TenantId.Create("11111111-1111-1111-1111-111111111111"),
name: "acme",
isActive: true));
store.AddOrUpdate(new TenantInfo(
id: TenantId.Create("22222222-2222-2222-2222-222222222222"),
name: "globex",
isActive: true));
});
var app = builder.Build();
// 5. Activate resolution middleware early in the request pipeline
app.UseMultiTenancy();
// 6. Define endpoints guarded with .RequireTenant()
app.MapGet("/api/tenant-profile", (ITenantContext tenantContext) =>
{
var tenant = tenantContext.RequiredTenant;
return Results.Ok(new { Tenant = tenant.Name, Id = tenant.Id.ToString() });
}).RequireTenant();
app.Run();using System.Data.Common;
using Dapper;
using EricksonLopez.MultiTenancy;
using EricksonLopez.MultiTenancy.Dapper;
public class InvoiceRepository
{
private readonly ITenantContext _tenantContext;
private readonly DbConnection _connection;
public InvoiceRepository(ITenantContext tenantContext, DbConnection connection)
{
_tenantContext = tenantContext;
_connection = connection;
}
public async Task<IEnumerable<Invoice>> GetInvoicesAsync()
{
// Explicit parameterization ensures Layer 1 Defense-in-Depth
var parameters = _tenantContext.CreateTenantParameters();
return await _connection.QueryAsync<Invoice>(
"SELECT * FROM invoices WHERE tenant_id = @TenantId",
parameters);
}
}using Dapper;
using EricksonLopez.MultiTenancy;
using EricksonLopez.MultiTenancy.PostgreSql;
using Npgsql;
public class SecureInvoiceService
{
private readonly NpgsqlConnection _connection;
private readonly ITenantContext _tenantContext;
public SecureInvoiceService(NpgsqlConnection connection, ITenantContext tenantContext)
{
_connection = connection;
_tenantContext = tenantContext;
}
public async Task ProcessOrdersAsync()
{
// Atomically opens connection and sets SET LOCAL app.current_tenant_id = :tenantId inside transaction
await using var transaction = await _connection.BeginTenantTransactionAsync(_tenantContext);
// Queries within this transaction are automatically filtered by PostgreSQL RLS
var invoices = await _connection.QueryAsync<Invoice>(
"SELECT * FROM invoices",
transaction: transaction);
await transaction.CommitAsync();
// Transaction completion automatically wipes session context β zero state leakage to connection pool
}
}using EricksonLopez.MultiTenancy;
using Microsoft.Extensions.DependencyInjection;
public class BackgroundReportWorker
{
private readonly ITenantStore _store;
private readonly ITenantScopeFactory _scopeFactory;
public BackgroundReportWorker(ITenantStore store, ITenantScopeFactory scopeFactory)
{
_store = store;
_scopeFactory = scopeFactory;
}
public async Task ExecuteTenantJobAsync(TenantId tenantId, CancellationToken ct)
{
// GetTenantAsync returns Result<ITenantInfo> β use IsSuccess to verify
var result = await _store.GetTenantAsync(tenantId, ct);
if (!result.IsSuccess || !result.Value.IsActive) return;
// Creates an isolated DI scope with pre-populated Scoped ITenantContext
await using var scope = _scopeFactory.CreateScope(result.Value);
var reportEngine = scope.ServiceProvider.GetRequiredService<IReportGenerator>();
await reportEngine.GenerateDailyAuditReportAsync(ct);
}
}In CQRS architectures, handlers enforce tenant boundaries through explicit constructor injection of ITenantContext, avoiding ambient static references.
using System.Data.Common;
using Dapper;
using EricksonLopez.MultiTenancy;
using EricksonLopez.MultiTenancy.Dapper;
using MediatR;
public sealed record GetCustomerByIdQuery(Guid CustomerId) : IRequest<CustomerDto?>;
public sealed class GetCustomerByIdHandler : IRequestHandler<GetCustomerByIdQuery, CustomerDto?>
{
private readonly ITenantContext _tenantContext;
private readonly DbConnection _dbConnection;
public GetCustomerByIdHandler(ITenantContext tenantContext, DbConnection dbConnection)
{
_tenantContext = tenantContext;
_dbConnection = dbConnection;
}
public async Task<CustomerDto?> Handle(GetCustomerByIdQuery request, CancellationToken ct)
{
// CreateTenantParameters() returns a DynamicParameters bag pre-populated with @TenantId.
var parameters = _tenantContext.CreateTenantParameters();
parameters.Add("CustomerId", request.CustomerId);
return await _dbConnection.QuerySingleOrDefaultAsync<CustomerDto>(
"SELECT id, name, email FROM customers WHERE id = @CustomerId AND tenant_id = @TenantId",
parameters);
}
}Configure multi-tier resolution where authenticated tokens always take precedence over subdomains or headers. Conflicting vectors fail immediately (ADR-008).
using EricksonLopez.MultiTenancy;
using EricksonLopez.MultiTenancy.AspNetCore;
builder.Services.AddMultiTenancy();
builder.Services.AddAspNetCoreMultiTenancy();
// 1. Priority 1 (Default): Authenticated JWT Claims ('tenant_id', 'tid')
// 2. Priority 2: Subdomain / HostName Strategy (acme.platform.com -> 'acme')
builder.Services.AddHostNameTenantStrategy();
// 3. Priority 3: Route Value (/api/{tenantId}/orders)
builder.Services.AddRouteTenantStrategy("tenantId");
// 4. Priority 4: Internal Header Strategy (Opt-In for trusted API gateways)
builder.Services.AddInternalHeaderTenantResolution(
expectedSharedSecret: "your-gateway-secret-here",
headerName: "X-Tenant-ID", // optional, default is "X-Tenant-ID"
secretHeaderName: "X-Gateway-Secret" // optional, default is "X-Gateway-Secret"
);Bind configuration options dynamically per tenant without restarting the application.
public sealed class TenantPaymentGatewayOptions
{
public string MerchantId { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public bool EnableCryptoCheckout { get; set; }
}
// In Program.cs:
builder.Services.AddPerTenantOptions<TenantPaymentGatewayOptions, TenantInfo>((options, tenant) =>
{
options.MerchantId = $"MERCHANT_{tenant.Name.ToUpperInvariant()}";
options.EnableCryptoCheckout = tenant.Name == "enterprise-corp";
});Consume messages from RabbitMQ, Azure Service Bus, or Hangfire while guaranteeing strict tenant isolation.
using EricksonLopez.MultiTenancy;
using Microsoft.Extensions.DependencyInjection;
public sealed class OrderPlacedConsumer
{
private readonly ITenantStore _tenantStore;
private readonly ITenantScopeFactory _scopeFactory;
public OrderPlacedConsumer(ITenantStore tenantStore, ITenantScopeFactory scopeFactory)
{
_tenantStore = tenantStore;
_scopeFactory = scopeFactory;
}
public async Task ConsumeAsync(OrderPlacedEvent message, CancellationToken ct)
{
// GetTenantAsync returns Result<ITenantInfo> β use IsSuccess and .Value
var result = await _tenantStore.GetTenantAsync(message.TenantId, ct);
if (!result.IsSuccess || !result.Value.IsActive)
{
throw new InvalidOperationException($"Invalid or inactive tenant {message.TenantId}");
}
// Creates a dedicated DI container scope with Scoped ITenantContext populated
await using var scope = _scopeFactory.CreateScope(result.Value);
var processor = scope.ServiceProvider.GetRequiredService<IOrderFulfillmentService>();
await processor.FulfillOrderAsync(message.OrderId, ct);
}
}Isolate cookie authentication sessions across different tenant subdomains to prevent session cross-contamination.
using EricksonLopez.MultiTenancy.Authentication;
builder.Services.AddPerTenantAuthentication<TenantInfo>();
builder.Services.AddScoped<TenantCookieAuthenticationEvents<TenantInfo>>();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "TenantCookieScheme";
})
.AddCookie("TenantCookieScheme", options =>
{
options.EventsType = typeof(TenantCookieAuthenticationEvents<TenantInfo>);
});For hybrid architectures where certain tenants require dedicated physical SQLite databases while others share infrastructure.
using System.Data.Common;
using EricksonLopez.MultiTenancy;
using EricksonLopez.MultiTenancy.Sqlite;
public sealed class TenantDatabaseRouter
{
private readonly ISqliteTenantConnectionFactory _connectionFactory;
private readonly ITenantContext _tenantContext;
public TenantDatabaseRouter(ISqliteTenantConnectionFactory connectionFactory, ITenantContext tenantContext)
{
_connectionFactory = connectionFactory;
_tenantContext = tenantContext;
}
public async Task<DbConnection> GetTenantDatabaseConnectionAsync(CancellationToken ct = default)
{
var tenant = _tenantContext.RequiredTenant;
return await _connectionFactory.CreateConnectionAsync(tenant.Id, ct);
}
}Integrate seamlessly into ASP.NET Core request pipelines with built-in endpoint security filters.
var app = builder.Build();
// 1. Resolution middleware must run after Authentication to access User Claims
app.UseAuthentication();
app.UseMultiTenancy();
app.UseAuthorization();
// 2. Secure endpoint groups with .RequireTenant()
var tenantGroup = app.MapGroup("/api/v1/workspaces")
.RequireTenant();
tenantGroup.MapGet("/", (ITenantContext context) =>
{
return Results.Ok(new { Tenant = context.RequiredTenant });
});Propagate tenant context across distributed traces via W3C Baggage and monitor resolution metrics.
using EricksonLopez.MultiTenancy.OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
builder.Services.AddMultiTenancyOpenTelemetry();
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddSource(TenantActivitySource.ActivitySourceName)
.AddAspNetCoreInstrumentation())
.WithMetrics(metrics => metrics
.AddMeter(TenantMetrics.MeterName));Verify store connectivity and tenant resolution health during application startup.
using EricksonLopez.MultiTenancy;
builder.Services.AddMultiTenancyHealthCheck(options =>
{
options.IncludeDiagnosticData = true;
options.StoreProbe = async (store, ct) =>
{
var probeResult = await store.GetTenantAsync(SeedTenants.AcmeId, ct);
return probeResult.IsSuccess;
};
});
builder.Services.AddHealthChecks();Mitigate database lookups during resolution by wrapping underlying stores with memory caching.
// 1. Register base database store
builder.Services.AddPostgreSqlTenantStore<TenantInfo>(connectionString);
// 2. Wrap with thread-safe IMemoryCache decorator
builder.Services.AddCachedTenantStore<TenantInfo>(options =>
{
options.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
options.SlidingExpiration = TimeSpan.FromMinutes(15);
});EricksonLopez.MultiTenancy.Analyzers evaluates code during compilation:
| Diagnostic ID | Severity | Category | Description | CodeFix Available |
|---|---|---|---|---|
ELMT001 |
Error | Security / Reliability | Prohibits storing ITenantContext or ITenantContextAccessor in static fields. |
β (Manual Refactor) |
ELMT002 |
Error | Architecture / DI | Prohibits injecting Scoped ITenantContext into Singleton lifetime services. |
β (Manual Refactor) |
ELMT003 |
Warning | Defense-in-Depth | Warns when Dapper queries are executed without explicit tenant parameter helpers. | β (Auto Parameterize) |
For enterprise SaaS applications that model multi-dimensional organizational structures, the Abstractions package provides a hierarchy of context interfaces:
using EricksonLopez.MultiTenancy;
// IBranchContext β operational location / physical branch context
public interface IBranchContext
{
Guid? BranchId { get; }
IReadOnlyList<Guid> AllowedBranchIds { get; }
bool AllBranchesAllowed { get; }
}
// ICompanyContext β legal entity / company context
public interface ICompanyContext
{
Guid? CompanyId { get; }
bool HasCompanyContext { get; } // true when CompanyId is assigned and non-empty
}
// IOrganizationContext β combines ITenantContext + ICompanyContext + IBranchContext
public interface IOrganizationContext : ITenantContext, ICompanyContext, IBranchContext { }Use OrganizationDapperExtensions (from EricksonLopez.MultiTenancy.Dapper) for Dapper query parameterization across all organizational dimensions:
// Add company and branch parameters alongside TenantId
var parameters = new DynamicParameters();
parameters.WithTenant(tenantContext);
parameters.WithCompany(organizationContext);
parameters.WithBranch(organizationContext);IPlatformAdminContext provides an explicit, non-nullable bypass contract for platform-level administrative operations that must span tenant boundaries without manipulating the ambient ITenantContext:
using EricksonLopez.MultiTenancy;
// Inject IPlatformAdminContext β only valid when IsPlatformAdmin is true
public class PlatformAdminReportingService
{
private readonly IPlatformAdminContext _adminContext;
public PlatformAdminReportingService(IPlatformAdminContext adminContext)
{
_adminContext = adminContext;
}
public async Task GenerateCrossOrganizationReportAsync(CancellationToken ct)
{
if (!_adminContext.IsPlatformAdmin)
throw new UnauthorizedAccessException("Platform admin context required.");
// Safe to query across tenants here
}
}HttpRemoteTenantStore<TTenant> fetches tenant metadata from a remote HTTP catalog service in microservice architectures:
builder.Services.AddHttpRemoteTenantStore<TenantInfo>(options =>
{
options.BaseAddress = new Uri("https://tenant-catalog.internal/");
options.Timeout = TimeSpan.FromSeconds(5);
options.EndpointTemplate = "/api/tenants/{0}"; // optional, this is the default
});
// Typically combined with CachedTenantStore to avoid per-request HTTP calls:
builder.Services.AddCachedTenantStore<TenantInfo>(options =>
{
options.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
});The EricksonLopez.MultiTenancy.Testing package provides pre-configured fakes:
FakeTenantStore<TTenant>: In-memory store double for unit testing lookups.FakeTenantResolutionStrategy: Configurable strategy double to simulate header, route, or JWT resolution.TenantContextBuilder: Fluent builder for creating populatedITenantContextinstances without mock libraries.FakeDbInfrastructure: In-memory ADO.NET connection and transaction doubles.
using EricksonLopez.MultiTenancy;
using EricksonLopez.MultiTenancy.Testing;
using Xunit;
public sealed class CustomerServiceTests
{
[Fact]
public async Task GetCustomerAsync_WhenTenantIsActive_ReturnsScopedCustomer()
{
// 1. Arrange: Build test doubles
var tenantId = TenantId.NewId();
var tenantContext = new TenantContextBuilder()
.WithId(tenantId)
.WithName("test-tenant")
.WithIsActive(true)
.Build();
var fakeDb = new FakeDbConnection();
var sut = new CustomerService(tenantContext, fakeDb);
// 2. Act
var result = await sut.GetCustomerAsync(Guid.NewGuid());
// 3. Assert
Assert.NotNull(result);
Assert.Equal(tenantId, sut.CurrentTenantId);
}
}The solution includes an automated architectural test suite (EricksonLopez.MultiTenancy.ArchitectureTests) powered by ArchUnitNET and NetArchTest.Rules:
- Verifies that
Abstractionsdoes not reference database drivers (Npgsql,Microsoft.Data.SqlClient). - Ensures
ITenantContextAccessoris never registered as a Singleton. - Guarantees Native AOT rules (no unannotated reflection in pipeline handlers).
| Metric | Target | Verified Status | Quality Gate |
|---|---|---|---|
| Line Coverage | 100.00% | 100.00% (15/15 packages) | Required on all PRs |
| Branch Coverage | 100.00% | 100.00% (15/15 packages) | Required on all PRs |
| Method Coverage | 100.00% | 100.00% (15/15 packages) | Required on all PRs |
| Stryker Mutation Score | β₯ 95.00% | 100.00% (High Threshold) | Verified in publish.yml |
| Native AOT Smoke Test | 100% Pass | 100% Pass (AotSmokeTest) |
Required in CI |
Environment: .NET 10.0.10, X64 RyuJIT AVX-512, BenchmarkDotNet v0.15.8
Comparison of EricksonLopez.MultiTenancy against conventional ambient dictionary and reflection-based multitenancy models:
| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated |
|---|---|---|---|---|---|---|
TenantId.Create(Guid) |
1.85 ns | 0.04 ns | 0.03 ns | 1.00 | - | 0 B |
TenantContextAccessor.GetContext() |
1.12 ns | 0.02 ns | 0.01 ns | 0.61 | - | 0 B |
TenantId.StructEquality |
0.42 ns | 0.01 ns | 0.01 ns | 0.23 | - | 0 B |
Conventional_AmbientAsyncLocal_DictionaryLookup |
18.94 ns | 0.35 ns | 0.28 ns | 10.24 | 0.0038 | 24 B |
Conventional_ConcurrentDictionary_CacheLookup |
24.15 ns | 0.42 ns | 0.38 ns | 13.05 | 0.0076 | 48 B |
Conventional_Regex_SubdomainExtraction |
142.80 ns | 2.10 ns | 1.85 ns | 77.19 | 0.0229 | 144 B |
Conventional_String_Boxing_And_Concatenation |
88.50 ns | 1.25 ns | 1.10 ns | 47.84 | 0.0305 | 192 B |
Execution timings for single vs multi-strategy resolution chains:
| Method | Mean | Ratio | Gen0 | Allocated |
|---|---|---|---|---|
SingleStrategy_Header_Resolve (Baseline) |
32.4 ns | 1.00 | - | 0 B |
SingleStrategy_Claim_Resolve |
28.1 ns | 0.87 | - | 0 B |
SingleStrategy_BasePath_Resolve |
21.6 ns | 0.67 | - | 0 B |
MultiStrategy_Sequential_Fallback |
34.2 ns | 1.05 | - | 0 B |
MultiStrategy_FailClosed_ConflictDetection |
48.7 ns | 1.50 | - | 0 B |
Overhead of setting up Row Level Security and Dapper parameters:
| Method | Mean | Ratio | Gen0 | Allocated |
|---|---|---|---|---|
Dapper_CreateTenantParameters (Baseline) |
42.1 ns | 1.00 | 0.0178 | 112 B |
Dapper_WithTenant_Extension |
46.5 ns | 1.10 | 0.0178 | 112 B |
PostgreSql_SetLocal_RlsContext |
68.4 ns | 1.62 | 0.0204 | 128 B |
SqlServer_SetSessionContext |
64.2 ns | 1.52 | 0.0204 | 128 B |
| Package | .NET 8.0 LTS | .NET 9.0 STS | .NET 10.0ΒΉ | NativeAOT | Trimmable | SNK Signed |
|---|---|---|---|---|---|---|
EricksonLopez.MultiTenancy.Abstractions |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy (Core) |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.Analyzers |
netstandard2.0 |
netstandard2.0 |
netstandard2.0 |
N/A | N/A | β |
EricksonLopez.MultiTenancy.AspNetCore |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.Authentication |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.Configuration |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.Dapper |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.PostgreSql |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.SqlServer |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.MySql |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.MariaDb |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.Oracle |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.Sqlite |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.OpenTelemetry |
β | β | β‘ | β | β | β |
EricksonLopez.MultiTenancy.Testing |
β | β | β‘ | β | β | β |
ΒΉ β‘ Forward-compatible via
net9.0binary. Packages targetnet8.0;net9.0explicitly. .NET 10.0 projects consume thenet9.0TFM through forward-compatibility. Full backward compatibility is guaranteed until Microsoft officially reaches End-of-Life (EOL) for .NET 8 and .NET 9 in November 2026, at which milestone the ecosystem will transition to .NET 10 and .NET 11 as first-class TFM targets.
| Database Dialect | Isolation Mechanism | Transaction Scoping | Pooled Connection Safe |
|---|---|---|---|
| PostgreSQL | SET LOCAL app.current_tenant_id = :tenantId + RLS |
β Yes | β 100% Safe (Auto Cleared) |
| Microsoft SQL Server | sp_set_session_context 'tenant_id', @TenantId |
β Yes | β 100% Safe (Reset on Rollback) |
| MySQL | @app_tenant_id session variable binding |
β Yes | β 100% Safe |
| MariaDB | @app_tenant_id session variable binding |
β Yes | β 100% Safe |
| Oracle Database | DBMS_SESSION.SET_IDENTIFIER + VPD |
β Yes | β 100% Safe |
| SQLite | Database-per-tenant (ISqliteTenantConnectionFactory) |
β Yes | β 100% Safe |
| Scenario | HTTP Status Code | RFC 9457 Problem Details Type | Action |
|---|---|---|---|
| Strategy Conflict Detected (ADR-008) | 409 Conflict |
https://httpstatuses.com/409#tenant-conflict |
Abort request immediately (throws TenantResolutionConflictException or writes Problem Details) |
| Missing Required Tenant | 401 Unauthorized |
https://httpstatuses.com/401#missing-tenant |
Challenge authentication |
| Tenant Inactive / Disabled | 403 Forbidden |
https://httpstatuses.com/403#tenant-inactive |
Reject client access |
| Tenant Not Found in Store | 404 Not Found |
https://httpstatuses.com/404#tenant-not-found |
Terminate routing |
flowchart TD
subgraph L1["Layer 1: Application Layer"]
L1_App["Explicit SQL Parameters\nWHERE tenant_id = @TenantId"]
end
subgraph L2["Layer 2: Infrastructure Layer"]
L2_Accessor["Scoped Write-Once Accessor\nITenantContext (No Static Leaks)"]
L2_Dapper["Dapper Extension Helpers\nWithTenant(parameters)"]
end
subgraph L3["Layer 3: Transaction-Scoped Context"]
L3_Txn["Atomic SET LOCAL app.current_tenant_id\nWithin Database Transaction"]
end
subgraph L4["Layer 4: Database Engine RLS"]
L4_RLS["PostgreSQL FORCE ROW LEVEL SECURITY\nRESTRICTIVE USING & WITH CHECK"]
end
L1 --> L2
L2 --> L3
L3 --> L4
sequenceDiagram
autonumber
actor Client as HTTP Client
participant MW as TenantResolutionMiddleware
participant Strat as Resolution Strategies
participant Store as ITenantStore
participant Acc as ScopedTenantContextAccessor
participant Endpoint as Minimal API / Controller
Client->>MW: HTTP Request
MW->>Strat: Execute Strategies (Claim > Host > Route > Header)
Strat-->>MW: Candidate TenantId
alt Strategy Conflict Detected
MW-->>Client: HTTP 409 Conflict (ADR-008 Conflict) or throws TenantResolutionConflictException
else Resolved TenantId
MW->>Store: GetTenantAsync(tenantId)
Store-->>MW: ITenantInfo (IsActive check)
alt Inactive / Not Found
MW-->>Client: HTTP 401 Unauthorized / 404 Not Found
else Active Tenant
MW->>Acc: Set TenantContext (Write-Once)
MW->>Endpoint: Next(HttpContext)
Endpoint-->>Client: HTTP 200 OK Response
end
end
stateDiagram-v8
[*] --> Unresolved : Request Initiated
Unresolved --> Resolving : TenantResolutionMiddleware
Resolving --> ConflictDetected : Conflicting Strategies
ConflictDetected --> Terminated : HTTP 409 Conflict
Resolving --> Resolved : Matching Candidate
Resolved --> StoreLookup : ITenantStore.GetTenantAsync
StoreLookup --> NotFound : Unknown TenantId
NotFound --> Terminated : HTTP 404 Not Found
StoreLookup --> Inactive : IsActive == false
Inactive --> Terminated : HTTP 403 Forbidden
StoreLookup --> Active : IsActive == true
Active --> Initialized : Write ScopedTenantContextAccessor
Initialized --> ExecutingPipeline : Endpoint Execution
ExecutingPipeline --> [*] : Scope Disposal & Reset
graph TD
Abstractions["EricksonLopez.MultiTenancy.Abstractions\n(L0: Pure Contracts & TenantId)"]
Core["EricksonLopez.MultiTenancy\n(L1: Engine & Scope Factory)"]
Analyzers["EricksonLopez.MultiTenancy.Analyzers\n(L2: Roslyn Rules)"]
AspNetCore["EricksonLopez.MultiTenancy.AspNetCore\n(L3: Middleware & Strategies)"]
Authentication["EricksonLopez.MultiTenancy.Authentication\n(L3: Per-Tenant Auth)"]
Configuration["EricksonLopez.MultiTenancy.Configuration\n(L3: IConfiguration Store)"]
Dapper["EricksonLopez.MultiTenancy.Dapper\n(L4: Parameters)"]
OpenTelemetry["EricksonLopez.MultiTenancy.OpenTelemetry\n(L6: Tracing & Metrics)"]
Testing["EricksonLopez.MultiTenancy.Testing\n(L7: Test Harness)"]
PostgreSql["EricksonLopez.MultiTenancy.PostgreSql\n(L5: PostgreSQL RLS)"]
SqlServer["EricksonLopez.MultiTenancy.SqlServer\n(L5: SESSION_CONTEXT)"]
MySql["EricksonLopez.MultiTenancy.MySql\n(L5: MySQL Session)"]
MariaDb["EricksonLopez.MultiTenancy.MariaDb\n(L5: MariaDB Session)"]
Oracle["EricksonLopez.MultiTenancy.Oracle\n(L5: Oracle VPD)"]
Sqlite["EricksonLopez.MultiTenancy.Sqlite\n(L5: DB-Per-Tenant)"]
Core --> Abstractions
Analyzers --> Abstractions
AspNetCore --> Abstractions
Authentication --> AspNetCore
Configuration --> Core
Configuration --> Abstractions
Dapper --> Abstractions
OpenTelemetry --> Abstractions
Testing --> Core
Testing --> Abstractions
PostgreSql --> Abstractions
PostgreSql --> Dapper
SqlServer --> Abstractions
SqlServer --> Dapper
MySql --> Abstractions
MySql --> Dapper
MariaDb --> Abstractions
MariaDb --> Dapper
Oracle --> Abstractions
Oracle --> Dapper
Sqlite --> Abstractions
Sqlite --> Dapper
| Scenario | β Avoid | β Recommended |
|---|---|---|
| Context Storage | Storing ITenantContext in static fields (ELMT001). |
Injecting ITenantContext into Scoped constructors. |
| Service Lifetime | Injecting ITenantContext into Singleton services (ELMT002). |
Registering services as Scoped or using ITenantScopeFactory. |
| SQL Queries | Omitting @TenantId parameter in Dapper queries (ELMT003). |
Using _tenantContext.CreateTenantParameters(...). |
| Database Isolation | Using session-level SET app.tenant_id = ... (Leaks in pool). |
Using transaction-scoped SET LOCAL inside transactions. |
| Resolution Precedence | Allowing HTTP headers (X-Tenant-ID) to override JWT claims. |
Enforcing claims-first priority and failing closed on conflicts (ADR-008). |
| Background Processing | Sharing ambient AsyncLocal state across background threads. |
Creating isolated scopes via ITenantScopeFactory.CreateScope(tenant). |
| Identifier Types | Using un-typed string tenantId or Guid? nullable values. |
Using immutable TenantId readonly record struct. |
| Query Rewriting | Dynamic regex or AST SQL string rewriting in request hot paths. | Writing explicit SQL with PostgreSQL RLS as database-tier enforcement. |
Caution
Always ensure database connections connect using an unprivileged application role (e.g., app_user). Connecting as PostgreSQL postgres superuser bypasses RLS policies unless FORCE ROW LEVEL SECURITY is applied.
- Symptom: API returns
HTTP 409 Conflict(whenWriteProblemDetailsOnConflict = true) or throwsTenantResolutionConflictException(whenfalse) with an ADR-008 conflict message. - Root Cause: Two configured strategies resolved conflicting tenant identifiers on the same request (e.g., JWT Claim was Tenant A, but
X-Tenant-IDheader was Tenant B). - Remediation: Remove contradictory client headers. Claims always take precedence in authenticated contexts.
- Symptom: Exception thrown when setting
tenantContextAccessor.TenantContext = .... - Root Cause: Attempting to mutate tenant context within an active request scope.
ScopedTenantContextAccessoris write-once per scope. - Remediation: Never overwrite active context. For background tasks targeting different tenants, create a new DI scope via
ITenantScopeFactory.CreateScope(targetTenant).
- Symptom: Queries execute without throwing errors but return empty datasets.
- Root Cause: PostgreSQL RLS is enabled on the table, but
SET LOCAL app.current_tenant_idwas not executed, causingcurrent_setting('app.current_tenant_id', true)to returnNULL. - Remediation: Execute database operations within
connection.BeginTenantTransactionAsync(tenantContext)referencing the active transaction wrapper.
- Symptom: Build fails with
ELMT002error. - Root Cause: A singleton service captures a scoped tenant context, creating a captive dependency.
- Remediation: Change the service lifetime to
Scopedor injectIServiceProvider/ITenantScopeFactoryto resolve the context on-demand.
EricksonLopez.MultiTenancy is an integral component of the Erickson Lopez enterprise .NET architectural ecosystem:
- π§± EricksonLopez.SharedKernel β Foundational Domain Primitives, Specifications, and Domain Events for Clean Architecture.
- β‘ EricksonLopez.Result β High-Performance Struct-Based Result Pattern and Railway-Oriented Programming.
- π EricksonLopez.Specification β Composable, Native AOT-First Specification Pattern for Query & Validation.
- π EricksonLopez.Mediator β Zero-Allocation Struct-Based In-Memory CQRS Mediator.
We welcome community contributions. Please adhere to the following workflow for local development:
- .NET 10.0 SDK, .NET 9.0 SDK, or .NET 8.0 SDK.
- Git.
- Stryker.NET (
dotnet tool install --global dotnet-stryker).
# 1. Clone the repository
git clone https://github.com/ericksonlopezf/dotnet-multitenancy.git
cd dotnet-multitenancy
# 2. Build solution in Release configuration
dotnet build EricksonLopez.MultiTenancy.slnx --configuration Release
# 3. Run complete unit, integration, and architecture test suites
dotnet test EricksonLopez.MultiTenancy.slnx --configuration Release
# 4. Run mutation testing gate
dotnet stryker --config-file stryker-config.jsonPlease review the Contributing Guidelines and Code of Conduct prior to submitting Pull Requests.
Distributed under the MIT License. Copyright Β© 2026 Erickson Lopez.