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.
- 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
ITenantContextthroughout your request - Pluggable Tenant Store — Implement
ITenantStorefor 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
dotnet add package JG.TenantKitvar 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();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);
}
}[ApiController]
[Route("[controller]")]
public class OrdersController(ITenantContext tenantContext)
{
[HttpGet]
public IActionResult GetOrders()
{
return Ok(new { TenantId = tenantContext.TenantId });
}
}Extract tenant from HTTP header:
services.AddHeaderTenantResolver("X-Tenant-ID");
services.AddHeaderTenantResolver("X-Org"); // Custom headerExtract 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);Extract tenant from first route segment (e.g., /acme/orders → "acme"):
services.AddRouteTenantResolver();Extract tenant from query parameter:
services.AddQueryStringTenantResolver("tenant");
services.AddQueryStringTenantResolver("org"); // Custom parameterExtract tenant from JWT claims:
services.AddClaimTenantResolver("tenant_id");
services.AddClaimTenantResolver("org_id"); // Custom claimChain multiple resolvers (first match wins):
services
.AddTenantKit()
.AddHeaderTenantResolver() // Try header first
.AddSubdomainTenantResolver() // Then subdomain
.AddRouteTenantResolver(); // Then routeFor development and testing:
var tenants = new Dictionary<string, TenantInfo>
{
["acme"] = new("acme", "ACME Corp", true, "Server=localhost;Database=acme"),
};
services.AddInMemoryTenantStore(tenants);Read from appsettings.json:
{
"Tenants": {
"acme": {
"DisplayName": "ACME Corp",
"IsEnabled": true,
"ConnectionString": "Server=acme.db;Database=acme",
"Properties": {
"PlanType": "Enterprise"
}
}
}
}services.AddConfigurationTenantStore();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>();Add transparent caching:
services
.AddInMemoryTenantStore(tenants)
.AddCaching(TimeSpan.FromMinutes(5));| Scenario | Status Code |
|---|---|
| Tenant resolved successfully | 200 OK |
| Tenant required but not found | 400 Bad Request |
| Tenant disabled | 403 Forbidden |
options.FallbackTenantId = "default"; // Optional fallback tenant
options.RequireResolution = true; // Require tenant (400 if missing)
options.CacheTtl = TimeSpan.FromMinutes(5); // Cache duration- 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
Apache License 2.0. See LICENSE for details.
Contributions welcome! Please open an issue or pull request.