Multi-tenancy library for .NET 10 (C# 14) using native Microsoft DI keyed services — no third-party containers required.
- Tenant resolution per HTTP request via a simple resolver interface
- Per-tenant service registration using native Microsoft DI keyed services — no third-party containers
- Transparent unkeyed proxy — controllers and handlers stay unaware of multitenancy; inject
IMyServiceand get the right tenant implementation automatically - Fluent builder with by-key, predicate, and all-tenants registration
- Microsoft Orleans support — tenant-scoped grain keys and a grain activator that propagates tenant context once per grain activation
| Package | Description |
|---|---|
Sketch7.Multitenancy |
Core abstractions and builder (ITenant, ITenantAccessor, MultitenancyBuilder) |
Sketch7.Multitenancy.AspNet |
ASP.NET Core middleware and HTTP resolver |
Sketch7.Multitenancy.Orleans |
Orleans grain activator and tenant grain key helpers |
Implement ITenant — the only requirement is a string Key. Use a record for immutability:
public record AppTenant : ITenant
{
public required string Key { get; init; }
public required string Name { get; init; }
public required string Organization { get; init; }
}public sealed class AppTenantRegistry : IAppTenantRegistry
{
private static readonly AppTenant[] _tenants =
[
new() { Key = "lol", Name = "League of Legends", Organization = "riot" },
new() { Key = "hots", Name = "Heroes of the Storm", Organization = "blizzard" },
];
public AppTenant Get(string key)
=> GetOrDefault(key) ?? throw new KeyNotFoundException($"Tenant '{key}' not found.");
public AppTenant? GetOrDefault(string key)
=> _tenants.FirstOrDefault(t => t.Key == key);
public IEnumerable<AppTenant> GetAll() => _tenants;
}// Program.cs
var tenantRegistry = new AppTenantRegistry();
builder.Services
.AddSingleton<AppTenantRegistry>(tenantRegistry);
builder.Services
.AddMultitenancy<AppTenant>(opts => opts
.WithRegistry(tenantRegistry)
.WithHttpResolver<AppTenant, AppTenantHttpResolver>()
.WithServices(tsb => tsb
// Register different IHeroDataClient implementations per tenant group
.For(t => t.Organization == "riot", s => s
.AddScoped<IHeroDataClient, LoLHeroDataClient>())
.For(t => t.Organization == "blizzard", s => s
.AddScoped<IHeroDataClient, HotsHeroDataClient>())
)
);The resolver extracts the tenant identifier from the incoming request (header, host, route, etc.):
public sealed class AppTenantHttpResolver : ITenantHttpResolver<AppTenant>
{
private readonly AppTenantRegistry _registry;
public AppTenantHttpResolver(AppTenantRegistry registry)
=> _registry = registry;
public Task<AppTenant?> Resolve(HttpContext httpContext)
{
httpContext.Request.Headers.TryGetValue("X-Tenant", out var tenantKey);
return Task.FromResult(_registry.GetOrDefault(tenantKey.ToString()));
}
}Add UseMultitenancy<T>() before any middleware that requires the resolved tenant (e.g. auth, routing, controllers):
app.UseMultitenancy<AppTenant>();
app.MapControllers();When tenant resolution fails the middleware returns 400 Bad Request with {"errorCode":"error.tenant.invalid"}.
Because MultitenancyBuilder registers unkeyed proxies, you inject the interface as normal — the right implementation for the current tenant is resolved automatically:
app.MapGet("/heroes", async (IHeroDataClient client) => // resolves to LoL or HoTS implementation
TypedResults.Ok(await client.GetAll()));app.MapGet("/tenant", (ITenantAccessor<AppTenant> tenantAccessor) =>
TypedResults.Ok(tenantAccessor.Tenant?.Name ?? "unknown"));All per-tenant registrations live inside WithServices. You can mix by-key, predicate, and all-tenants registrations in any order:
builder.Services
.AddMultitenancy<AppTenant>(opts => opts
.WithRegistry(tenantRegistry) // makes tenants available for predicates
.WithServices(tsb => tsb
// by exact key
.For("lol", s => s.AddScoped<IHeroDataClient, LoLHeroDataClient>())
.For("hots", s => s.AddScoped<IHeroDataClient, HotsHeroDataClient>())
// by predicate (requires WithRegistry or WithTenants)
.For(t => t.Organization == "riot", s => s
.AddScoped<IHeroDataClient, LoLHeroDataClient>()
)
// same service for every tenant
.ForAll(s => s.AddScoped<IAuditLogger, DefaultAuditLogger>())
)
);app.UseMultitenancy<AppTenant>(new MultitenancyMiddlewareOptions()
.WithInvalidTenantResponse(() => new { error = "tenant_not_found", status = 400 }));siloBuilder.UseMultitenancy<AppTenant>();Registers ITenantOrleansResolver<TTenant> and TenantGrainActivator<TTenant> — tenant context is set once per grain activation.
Grain keys follow the tenant/{tenantKey}/{grainId} format — the tenant/ prefix prevents ambiguous parsing when the grain ID itself contains /:
string key = TenantGrainKey.Create("lol", "hero-42"); // "tenant/lol/hero-42"
string tenantKey = TenantGrainKey.GetTenantKey(key); // "lol"
string grainKey = TenantGrainKey.GetGrainKey(key); // "hero-42"
// TryParse returns a TenantGrainKey record struct
if (TenantGrainKey.TryParse(key, out var parsed))
Console.WriteLine(parsed.TenantKey); // "lol"Two patterns are supported:
Constructor injection (recommended) — tenant context is set in ActivationServices before the grain is constructed, so tenant-aware services resolve correctly via the multitenancy proxy:
public sealed class HeroGrain : Grain, IHeroGrain
{
public HeroGrain(IHeroDataClient heroDataClient, ...) { ... }
}Property accessor — implement IWithTenantAccessor<T> to receive the AppTenant object directly inside grain methods:
public sealed class HeroTypeGrain : Grain, IHeroTypeGrain, IWithTenantAccessor<AppTenant>
{
public TenantAccessor<AppTenant> TenantAccessor { get; } = new();
}public interface IHeroGrain : IGrainWithStringKey, ITenantGrain
{
[AlwaysInterleave]
[return: Immutable]
Task<List<Hero>> GetAllAsync();
}