Skip to content

jamesgober/dotnet-tenant-kit

Repository files navigation

JG.TenantKit

NuGet Downloads License CI

A production-ready multi-tenancy library for .NET 8 ASP.NET Core applications. Resolve tenants from headers, subdomains, routes, query strings, or JWT claims. Isolate data and configuration per tenant with scoped dependency injection.

Features

  • 5 Built-in Tenant Resolvers — Headers, subdomains, routes, query strings, JWT claims
  • Composable Resolution — Chain multiple resolvers with fallback support
  • Scoped Tenant Context — Access the current tenant via ITenantContext throughout your request
  • Pluggable Tenant Store — Implement ITenantStore for custom backends
  • 3 Built-in Stores — In-memory (dev), configuration-based (appsettings.json), or custom
  • Transparent Caching — TTL-based cache with case-insensitive lookups
  • Early Pipeline Resolution — Resolve tenant before reaching your handlers
  • Proper Error Handling — HTTP 400 (missing) / 403 (disabled) responses
  • Zero External Dependencies — Uses only Microsoft.Extensions and Microsoft.AspNetCore
  • Full Test Coverage — 40+ tests covering all scenarios

Installation

dotnet add package JG.TenantKit

Quick Start

1. Register Services

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddTenantKit(options =>
    {
        options.FallbackTenantId = "default";
    })
    .AddHeaderTenantResolver("X-Tenant-ID")
    .AddSubdomainTenantResolver()
    .AddInMemoryTenantStore(new Dictionary<string, TenantInfo>
    {
        ["acme"] = new("acme", "ACME Corp", isEnabled: true),
        ["globex"] = new("globex", "Globex Corp", isEnabled: true)
    });

var app = builder.Build();

// Add middleware early in pipeline
app.UseTenantResolution();

app.Run();

2. Use in Your Services

public class OrderService(ITenantContext tenantContext, ITenantStore store)
{
    public async Task<List<Order>> GetOrdersAsync()
    {
        if (!tenantContext.IsResolved)
            throw new InvalidOperationException("Tenant not resolved");

        var tenant = await store.GetByIdAsync(tenantContext.TenantId);
        // Use tenant.ConnectionString for data isolation
        
        return await LoadOrdersAsync(tenantContext.TenantId);
    }
}

3. Use Tenant Context in Controllers

[ApiController]
[Route("[controller]")]
public class OrdersController(ITenantContext tenantContext)
{
    [HttpGet]
    public IActionResult GetOrders()
    {
        return Ok(new { TenantId = tenantContext.TenantId });
    }
}

Tenant Resolvers

HeaderResolver

Extract tenant from HTTP header:

services.AddHeaderTenantResolver("X-Tenant-ID");
services.AddHeaderTenantResolver("X-Org");  // Custom header

SubdomainResolver

Extract tenant from subdomain (e.g., acme.example.com"acme"):

services.AddSubdomainTenantResolver();  // Ignores "www", "api" by default

// Custom ignored subdomains
var ignored = new HashSet<string> { "staging", "test" };
services.AddSubdomainTenantResolver(ignored);

RouteResolver

Extract tenant from first route segment (e.g., /acme/orders"acme"):

services.AddRouteTenantResolver();

QueryStringResolver

Extract tenant from query parameter:

services.AddQueryStringTenantResolver("tenant");
services.AddQueryStringTenantResolver("org");  // Custom parameter

ClaimResolver

Extract tenant from JWT claims:

services.AddClaimTenantResolver("tenant_id");
services.AddClaimTenantResolver("org_id");  // Custom claim

Composite Resolution

Chain multiple resolvers (first match wins):

services
    .AddTenantKit()
    .AddHeaderTenantResolver()       // Try header first
    .AddSubdomainTenantResolver()    // Then subdomain
    .AddRouteTenantResolver();       // Then route

Tenant Stores

InMemoryTenantStore

For development and testing:

var tenants = new Dictionary<string, TenantInfo>
{
    ["acme"] = new("acme", "ACME Corp", true, "Server=localhost;Database=acme"),
};

services.AddInMemoryTenantStore(tenants);

ConfigurationTenantStore

Read from appsettings.json:

{
  "Tenants": {
    "acme": {
      "DisplayName": "ACME Corp",
      "IsEnabled": true,
      "ConnectionString": "Server=acme.db;Database=acme",
      "Properties": {
        "PlanType": "Enterprise"
      }
    }
  }
}
services.AddConfigurationTenantStore();

Custom Store

Implement ITenantStore:

public class DatabaseTenantStore : ITenantStore
{
    public async ValueTask<TenantInfo?> GetByIdAsync(string tenantId, CancellationToken cancellationToken = default)
    {
        // Fetch from your database
        return await FetchTenantAsync(tenantId, cancellationToken);
    }
}

services.AddTenantStore<DatabaseTenantStore>();

Caching

Add transparent caching:

services
    .AddInMemoryTenantStore(tenants)
    .AddCaching(TimeSpan.FromMinutes(5));

Error Handling

Scenario Status Code
Tenant resolved successfully 200 OK
Tenant required but not found 400 Bad Request
Tenant disabled 403 Forbidden

Configuration

options.FallbackTenantId = "default";        // Optional fallback tenant
options.RequireResolution = true;            // Require tenant (400 if missing)
options.CacheTtl = TimeSpan.FromMinutes(5);  // Cache duration

Performance

  • Scoped Lifetime — One context per request
  • ValueTask — Zero allocations on cache hits
  • ConcurrentDictionary — Thread-safe caching
  • ConfigureAwait(false) — Library best practices
  • No LINQ — Efficient hot paths

License

Apache License 2.0. See LICENSE for details.

Contributing

Contributions welcome! Please open an issue or pull request.

About

Multi-tenancy for ASP.NET Core. Tenant resolution from subdomains, headers, routes, query strings, or JWT claims. Per-tenant config, data isolation, and caching.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages