Multi-Tenancy
WARNING
Once the axiom framework matures, we plan to introduce a tenant management module with database-backed tenants, a UI, and admin APIs. Until then, the current multi-tenancy features are considered "preview" and not ready to be used in production without custom implementation of tenant stores and principal access control.
Multi-tenancy is the ability to serve multiple independent tenants from a single application codebase. Each tenant has isolated data, configuration, and context but shares the same codebase and infrastructure.
Axiom handles this through an ambient tenant context: once the current tenant is resolved (from an HTTP header, route, query string, or any custom source), it is stored in an AsyncLocal slot and flows automatically through the async call chain. Your services read it without any explicit passing.
Three packages are involved:
dotnet add package Allegory.Axiom.MultiTenancy.Abstractions
dotnet add package Allegory.Axiom.MultiTenancy
dotnet add package Allegory.Axiom.MultiTenancy.DefaultStore # optional, for config-based tenantsFor ASP.NET Core integration:
dotnet add package Allegory.Axiom.AspNetCore.MultiTenancyHow Axiom Handles Multi-Tenancy
At request time (or at any async boundary), Axiom resolves a TenantContext an immutable record holding the tenant's Id, Name, NormalizedName, and any extra metadata and places it in the ambient context. From that point on, any service in the call chain can read the current tenant without knowing how it was resolved.
The flow looks like this:
Incoming request
→ ICurrentTenantIdentifierProvider (extract raw identifier: header / query / route / custom)
→ ITenantStore (look up TenantContext by id or name)
→ ICurrentTenantChecker (verify principal has access)
→ ITenantContextAccessor.Set(tenant) (store in AsyncLocal)
→ your services read ITenantContextAccessor.CurrentIn ASP.NET Core, MultiTenancyMiddleware drives this pipeline automatically at the start of each request.
Accessing the Current Tenant
Inject ITenantContextAccessor anywhere in your application to read the ambient tenant:
public class OrderService(ITenantContextAccessor accessor) : ITransientService
{
public Task<IEnumerable<Order>> GetOrdersAsync()
{
var tenantId = accessor.Current?.Id
?? throw new InvalidOperationException("No tenant context.");
// Use tenantId to filter data
return Task.FromResult(Enumerable.Empty<Order>());
}
}Current returns null when no tenant context has been set for example, in host-level background jobs or admin operations that do not belong to any tenant. A null check is the conventional way to detect host-level execution.
ITenantContextAccessor
public interface ITenantContextAccessor : ISingletonService
{
TenantContext? Current { get; }
void Set(TenantContext? current = null);
IDisposable Change(TenantContext? current = null);
}Set replaces the ambient context for the current async flow. Change replaces it temporarily and restores the previous value when the returned IDisposable is disposed useful for switching tenants within a background job or integration test:
using (accessor.Change(tenantB))
{
await DoWorkAsync(); // Current = tenantB
}
// Current restored to whatever it was beforeNested changes unwind correctly:
accessor.Set(tenantA);
using (accessor.Change(tenantB))
{
// Current = tenantB
using (accessor.Change(tenantC))
{
// Current = tenantC
}
// Current = tenantB
}
// Current = tenantAASP.NET Core Setup
Middleware
MultiTenancyMiddleware runs the resolution pipeline once per request and sets the ambient tenant. Register it after authentication so principal is available for current tenant checking, and before authorization so the tenant context is available to auth middleware:
app.UseRouting();
app.UseAuthentication();
app.UseMultiTenancy();
app.UseAuthorization();If no tenant identifier is found, the middleware does nothing and the request continues as a host-level operation (Current remains null).
Tenant Identification
By default, HttpContextCurrentTenantIdentifierProvider extracts the tenant identifier from the HTTP request in this priority order:
- Header
Tenant - Query string
__tenant - Route value
tenant
Configure the keys:
builder.Services.Configure<AspNetCoreMultiTenancyOptions>(options =>
{
options.HeaderKey = "Tenant"; // default
options.QueryKey = "__tenant"; // default
options.RouteKey = "tenant"; // default
});For route-based tenants, include the route key in your route template:
app.MapControllerRoute(
name: "tenant",
pattern: "{tenant}/{controller=Home}/{action=Index}/{id?}");Tenant Store
ITenantStore resolves a TenantContext from a raw identifier. Axiom calls it internally during resolution you do not call it directly in most cases.
public interface ITenantStore : ISingletonService
{
ValueTask<TenantContext?> FindAsync(Guid id);
ValueTask<TenantContext?> FindAsync(string name);
}Default Store
Allegory.Axiom.MultiTenancy.DefaultStore provides a configuration-driven implementation. Define tenants in appsettings.json under the Axiom key:
{
"Axiom": {
"Tenants": [
{
"id": "11111111-1111-1111-1111-111111111111",
"name": "acme",
"normalizedName": "ACME",
"extraProperties": {
"plan": "enterprise"
}
}
],
"TenantPrincipals": {
"user-id-here": ["11111111-1111-1111-1111-111111111111"]
}
}
}TenantPrincipals maps principal IDs (from the NameIdentifier claim) to the tenant IDs they may access.
Custom Store
For database-backed resolution, implement ITenantStore directly. Because NullTenantStore is registered with TryAdd, your implementation takes precedence automatically:
public class DatabaseTenantStore(IDbContext db) : ITenantStore
{
public async ValueTask<TenantContext?> FindAsync(Guid id)
{
var row = await db.Tenants.FindAsync(id);
return row == null ? null : new TenantContext(row.Id, row.Name, row.NormalizedName);
}
public async ValueTask<TenantContext?> FindAsync(string name)
{
var normalized = name.ToUpperInvariant();
var row = await db.Tenants.FirstOrDefaultAsync(t => t.NormalizedName == normalized);
return row == null ? null : new TenantContext(row.Id, row.Name, row.NormalizedName);
}
}Principal Access Control
After resolving a tenant from the store, Axiom checks whether the current authenticated principal is allowed to access it. This check runs automatically as part of the resolution pipeline.
ICurrentTenantChecker performs the check:
public interface ICurrentTenantChecker : ISingletonService
{
Task CheckAsync(TenantContext tenant);
}The default implementation:
- Reads the current principal via
IPrincipalAccessor. - Skips the check if the identity is
nullor not authenticated (unauthenticated requests are allowed through). - Throws if authenticated but no
NameIdentifierclaim is present. - Calls
ITenantPrincipalStore.HasAccessAsyncand throws if the principal lacks access.
Principal Store
public interface ITenantPrincipalStore : ISingletonService
{
Task<bool> HasAccessAsync(string principalId, Guid tenantId, CancellationToken cancellationToken = default);
ValueTask<IReadOnlySet<Guid>> GetTenantListAsync(string principalId, CancellationToken cancellationToken = default);
}NullTenantPrincipalStore is registered with TryAdd and always denies access. Replace it with your own implementation for production use.
Custom Checker
Override CurrentTenantChecker to extend or replace the access logic:
public class MyTenantChecker(
ITenantPrincipalStore store,
IPrincipalAccessor accessor) : CurrentTenantChecker(store, accessor)
{
public override async Task CheckAsync(TenantContext tenant)
{
await base.CheckAsync(tenant);
// additional checks here
}
}Tenant Resolution Outside the HTTP Pipeline
ICurrentTenantIdentifierProvider plugs into the resolution pipeline that MultiTenancyMiddleware drives on each HTTP request. For background jobs, message consumers, scheduled tasks, or any code that runs outside a request context, that pipeline never executes there is no middleware to call it.
In those cases, resolve the tenant yourself using ITenantStore and set it on ITenantContextAccessor directly:
public class OrderProcessingJob(
ITenantStore tenantStore,
ITenantContextAccessor tenantAccessor,
IOrderService orderService) : ITransientService
{
public async Task ProcessAsync(string tenantName)
{
var tenant = await tenantStore.FindAsync(tenantName)
?? throw new InvalidOperationException($"Tenant '{tenantName}' not found.");
using (tenantAccessor.Change(tenant))
{
// All code here sees the correct tenant via tenantAccessor.Current
await orderService.ProcessPendingOrdersAsync();
}
}
}Change restores the previous context when disposed, so tenant switches are safe to nest and compose.
Custom Identifier Providers
ICurrentTenantIdentifierProvider is for extending how the HTTP pipeline identifies the tenant for example, resolving it from a custom claim, a domain name, or a subdomain instead of a header or route value:
public interface ICurrentTenantIdentifierProvider : ISingletonService
{
ValueTask<string?> TryGetAsync();
}Return null or empty to indicate "no identifier from this source." Multiple providers can be registered; the first non-empty result wins.
// Resolve tenant from subdomain: acme.myapp.com → "acme"
public class SubdomainTenantIdentifierProvider(IHttpContextAccessor httpContextAccessor)
: ICurrentTenantIdentifierProvider
{
public ValueTask<string?> TryGetAsync()
{
var host = httpContextAccessor.HttpContext?.Request.Host.Host;
var subdomain = host?.Split('.').FirstOrDefault();
return ValueTask.FromResult(subdomain);
}
}Tenant Normalization
Names are normalized before lookup via ITenantNormalizer. The default uses ToUpperInvariant(), which avoids locale-specific issues (e.g., the Turkish dotted-I problem).
public interface ITenantNormalizer : ISingletonService
{
string NormalizeName(string name);
}Override to apply custom normalization:
public class MyTenantNormalizer : ITenantNormalizer
{
public string NormalizeName(string name) =>
name.Trim().ToUpperInvariant().Replace(" ", "-");
}Tenant-Owned Entities
Implement ITenantOwned on entities that belong to a specific tenant:
public interface ITenantOwned
{
Guid? TenantId { get; }
}TenantId is nullable to support host-level entities that do not belong to any tenant. Use ITenantContextAccessor.Current?.Id to stamp TenantId on new entities, and filter queries by it in your data access layer.