Skip to content

Latest commit

 

History

31 Commits

Folders and files

Repository files navigation

Dotnet Multitenancy

CI NuGet

Multi-tenancy library for .NET 10 (C# 14) using native Microsoft DI keyed services — no third-party containers required.

Features

  • 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 IMyService and 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

Packages

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

Getting Started

1. Define your tenant

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

2. Create a tenant registry

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

3. Register multitenancy services

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

4. Implement the HTTP resolver

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

5. Add the middleware

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


Usage

Inject tenant-specific services

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

Inject the current tenant directly

app.MapGet("/tenant", (ITenantAccessor<AppTenant> tenantAccessor) =>
    TypedResults.Ok(tenantAccessor.Tenant?.Name ?? "unknown"));

Configure per-tenant services

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

Customize the invalid-tenant response

app.UseMultitenancy<AppTenant>(new MultitenancyMiddlewareOptions()
    .WithInvalidTenantResponse(() => new { error = "tenant_not_found", status = 400 }));

Microsoft Orleans Integration

1. Configure the silo

siloBuilder.UseMultitenancy<AppTenant>();

Registers ITenantOrleansResolver<TTenant> and ConfigureTenantGrainActivator<TTenant> (IConfigureGrainTypeComponents) — installs a per-grain-type IGrainActivator that propagates tenant context before grain construction.

2. Grain keys

Two key formats are supported. The tenant/ prefix prevents ambiguous parsing when the grain ID itself contains /:

Format Use case
tenant/{tenantKey}/{grainId} Multiple grain instances per tenant
tenant/{tenantKey} One grain instance per tenant (tenant-only)
// grain-specific key
string key = TenantGrainKey.Create("lol", "hero-42"); // "tenant/lol/hero-42"
string tenantKey = TenantGrainKey.GetTenantKey(key);  // "lol"
string grainKey  = TenantGrainKey.GetGrainKey(key);   // "hero-42"

// tenant-only key — one grain instance per tenant
string tenantOnlyKey = TenantGrainKey.Create("lol"); // "tenant/lol"

// TryParse returns a TenantGrainKey record struct
if (TenantGrainKey.TryParse(key, out var parsed))
{
    Console.WriteLine(parsed.TenantKey);   // "lol"
    Console.WriteLine(parsed.IsTenantOnly); // false for grain-specific, true for tenant-only
}

3. Grain authoring

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

4. Grain interface

public interface IHeroGrain : IGrainWithStringKey, ITenantGrain
{
    [AlwaysInterleave]
    [return: Immutable]
    Task<List<Hero>> GetAllAsync();
}

Releases

Packages

Used by

Contributors

Languages