Skip to content

Latest commit

Β 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

EricksonLopez.MultiTenancy

Secure, high-performance, Native AOT-compatible, enterprise-grade multi-tenancy ecosystem and 4-layer Defense-in-Depth isolation for modern .NET.

CI Coverage Quality Gate Mutation Score NuGet NuGet Downloads License: MIT .NET NativeAOT


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.


Table of Contents


🎯 What Problem It Solves

Traditional Multi-Tenancy Anti-Patterns

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

How EricksonLopez.MultiTenancy Solves This

  • πŸ›‘οΈ 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 via SET LOCAL (PostgreSQL) or sp_set_session_context (SQL Server). When a transaction completes (COMMIT or ROLLBACK), the database automatically purges the variable, returning clean connections to the pool.
  • 🧱 Immutable, Zero-Allocation Struct TenantId: A 128-bit Guid-backed readonly record struct implementing IEquatable<TenantId> and IComparable<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 throwing TenantResolutionConflictException (when false).
  • πŸ•΅οΈ Compile-Time Roslyn Analyzers: Analyzers ELMT001, ELMT002, and ELMT003 intercept 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.

⚑ Key Features

  • πŸ›‘οΈ 4-Layer Defense-in-Depth Architecture: Guarantees isolation even if application code fails to apply a WHERE filter.
  • πŸš€ Zero-Allocation TenantId Primitives: 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: ITenantScopeFactory creates 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, and TenantContextBuilder for frictionless unit and integration testing.

πŸ“¦ Ecosystem

Package Version Description
EricksonLopez.MultiTenancy.Abstractions NuGet Foundational contracts: TenantId, ITenantInfo, ITenantContext, ITenantResolver, ITenantScope, and TenantErrors.
EricksonLopez.MultiTenancy NuGet Core engine: ScopedTenantContextAccessor, DefaultTenantScopeFactory, InMemoryTenantStore, and CachedTenantStore.
EricksonLopez.MultiTenancy.Analyzers NuGet Roslyn analyzers: static field leaks (ELMT001), singleton captivity (ELMT002), un-scoped Dapper queries (ELMT003).
EricksonLopez.MultiTenancy.AspNetCore NuGet ASP.NET Core resolution middleware, JWT/Host/Route/Header strategies, .RequireTenant() endpoint filters.
EricksonLopez.MultiTenancy.Authentication NuGet Per-tenant authentication schemes, cookie validation events, dynamic scheme routing.
EricksonLopez.MultiTenancy.Configuration NuGet IConfiguration and IOptionsMonitor-backed tenant store for file-based configuration.
EricksonLopez.MultiTenancy.Dapper NuGet Dapper parameter builders and query parameter helpers (WithTenant, CreateTenantParameters).
EricksonLopez.MultiTenancy.PostgreSql NuGet PostgreSQL Row Level Security (RLS) enforcement via transaction-scoped SET LOCAL + PostgreSqlTenantStore.
EricksonLopez.MultiTenancy.SqlServer NuGet SQL Server SESSION_CONTEXT management via sp_set_session_context and security policies.
EricksonLopez.MultiTenancy.MySql NuGet MySQL session variable isolation (@app_tenant_id) and connection scoping.
EricksonLopez.MultiTenancy.MariaDb NuGet MariaDB session variable isolation (@app_tenant_id) and connection scoping.
EricksonLopez.MultiTenancy.Oracle NuGet Oracle Virtual Private Database (VPD) and DBMS_SESSION.SET_IDENTIFIER integration.
EricksonLopez.MultiTenancy.Sqlite NuGet SQLite database-per-tenant (ISqliteTenantConnectionFactory) and temp table session context.
EricksonLopez.MultiTenancy.OpenTelemetry NuGet Distributed tracing (TenantActivitySource), W3C Baggage propagation, metrics (TenantMetrics).
EricksonLopez.MultiTenancy.Testing NuGet Test doubles: FakeTenantStore, FakeTenantResolutionStrategy, TenantContextBuilder, FakeDbInfrastructure.

πŸ“š Documentation

🌐 Official Documentation Hub: https://github.com/ericksonlopezf/dotnet-multitenancy/tree/main/docs

πŸŽ“ Step-by-Step Interactive Showcase (Levels 00 to 08)

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

πŸ“– Technical Reference & Architecture Guides


πŸ“₯ Installation

Core Engine & Abstractions

# 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

Web & ASP.NET Core Hosting

# 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

Relational Database Dialects

# 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

Observability & Testing

# 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.Testing

πŸš€ Quick Start

1. Strongly-Typed Tenant Identity

using 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);

2. Dependency Injection & Pipeline Configuration

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();

3. Explicit SQL Queries with Dapper (Layer 1)

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);
    }
}

4. PostgreSQL Row Level Security Enforcement (Layers 3 & 4)

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
    }
}

5. Isolated Background Processing

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);
    }
}

πŸ’‘ Core Use Cases

1. Clean Architecture & CQRS Query Handlers

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);
    }
}

2. Multi-Strategy Resolution with Fail-Closed Conflict Detection

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"
);

3. Per-Tenant Configuration & Feature Options

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";
});

4. Background Job & Message Queue Consumers

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);
    }
}

5. Per-Tenant Authentication Schemes & Dynamic Cookies

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>);
});

6. Database-per-Tenant Dynamic Connection Routing

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);
    }
}

πŸ”Œ Configuration & Integrations

ASP.NET Core & Minimal APIs

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 });
});

OpenTelemetry Tracing & Metrics

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));

Multi-Tenancy Health Checks

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();

Caching & Tenant Store Strategies

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);
});

Roslyn Diagnostic Analyzers Reference

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)

Organization Hierarchy Interfaces

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);

Cross-Tenant Platform Admin Context

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
    }
}

HTTP Remote Tenant Store

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);
});

πŸ§ͺ Testing & Quality

Testing Primitives & Doubles

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 populated ITenantContext instances without mock libraries.
  • FakeDbInfrastructure: In-memory ADO.NET connection and transaction doubles.

Unit & Integration Test Example

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);
    }
}

Architectural Boundary Verification

The solution includes an automated architectural test suite (EricksonLopez.MultiTenancy.ArchitectureTests) powered by ArchUnitNET and NetArchTest.Rules:

  • Verifies that Abstractions does not reference database drivers (Npgsql, Microsoft.Data.SqlClient).
  • Ensures ITenantContextAccessor is never registered as a Singleton.
  • Guarantees Native AOT rules (no unannotated reflection in pipeline handlers).

Quality Gate & Mutation Testing Metrics

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

⚑ Performance Benchmarks

Environment: .NET 10.0.10, X64 RyuJIT AVX-512, BenchmarkDotNet v0.15.8

Primary Operations Benchmark

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

Multi-Strategy Resolution & Conflict Detection

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

Relational Database Session & Parameter Injection

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

🌐 Compatibility & Technical Matrix

Framework & Compilation Target Matrix

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.0 binary. Packages target net8.0;net9.0 explicitly. .NET 10.0 projects consume the net9.0 TFM 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.

Relational Database Dialect Isolation Matrix

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

HTTP Status Code & Security Error Mapping

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

πŸ›οΈ Architecture & Design Principles

4-Layer Defense-in-Depth Model

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
Loading

Request Resolution Lifecycle Sequence

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
Loading

Tenant Context Lifecycle State Machine

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
Loading

Package Layering & Dependency Hierarchy

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
Loading

πŸ›‘οΈ Best Practices & Anti-Patterns

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.

⚠️ Troubleshooting & Common Pitfalls

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.

1. TenantResolutionConflictException: Resolution conflict detected between strategies

  • Symptom: API returns HTTP 409 Conflict (when WriteProblemDetailsOnConflict = true) or throws TenantResolutionConflictException (when false) 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-ID header was Tenant B).
  • Remediation: Remove contradictory client headers. Claims always take precedence in authenticated contexts.

2. InvalidOperationException: ITenantContextAccessor has already been set

  • Symptom: Exception thrown when setting tenantContextAccessor.TenantContext = ....
  • Root Cause: Attempting to mutate tenant context within an active request scope. ScopedTenantContextAccessor is write-once per scope.
  • Remediation: Never overwrite active context. For background tasks targeting different tenants, create a new DI scope via ITenantScopeFactory.CreateScope(targetTenant).

3. PostgreSQL Query Returns 0 Rows Unexpectedly

  • 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_id was not executed, causing current_setting('app.current_tenant_id', true) to return NULL.
  • Remediation: Execute database operations within connection.BeginTenantTransactionAsync(tenantContext) referencing the active transaction wrapper.

4. Roslyn Diagnostic ELMT002: Cannot inject Scoped ITenantContext into Singleton

  • Symptom: Build fails with ELMT002 error.
  • Root Cause: A singleton service captures a scoped tenant context, creating a captive dependency.
  • Remediation: Change the service lifetime to Scoped or inject IServiceProvider / ITenantScopeFactory to resolve the context on-demand.

🌐 Part of the Ecosystem

EricksonLopez.MultiTenancy is an integral component of the Erickson Lopez enterprise .NET architectural ecosystem:


🀝 Contributing

We welcome community contributions. Please adhere to the following workflow for local development:

1. Prerequisites

2. Build & Verify Locally

# 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.json

Please review the Contributing Guidelines and Code of Conduct prior to submitting Pull Requests.


πŸ“„ License

Distributed under the MIT License. Copyright Β© 2026 Erickson Lopez.

About

Native AOT-first multi-tenancy ecosystem for modern .NET & ASP.NET Core with 4-layer Defense-in-Depth isolation across PostgreSQL RLS, SQL Server, MySQL, MariaDB, Oracle VPD, and SQLite.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages