Multi-Tenancy
Multi-tenant applications need the same configuration type to resolve to different values per tenant — a global default for everything, with each tenant overriding only the keys it sets and inheriting the rest.
Cocoar.Configuration models this as per-tenant pipeline bundles layered on a shared global base (see ADR-005). You author one flat rule list and mark the per-tenant rules with .TenantScoped(); the tenant id flows in through the configuration accessor. There is no second authoring surface and no provider becomes "tenant-aware".
When do I need this?
Only when one process serves many tenants and the same type must differ per tenant at runtime, with tenants added/removed dynamically. A single-tenant app needs none of this — the global pipeline is unchanged.
The two primitives
IConfigurationAccessor.Tenant—nullin the global (tenant-agnostic) pipeline, the tenant id inside a tenant pipeline. Tenant-varying rule factories interpolate it..TenantScoped()on a rule — the rule runs only for a tenant and is skipped in the global pipeline. It is shorthand for.When(a => !string.IsNullOrWhiteSpace(a.Tenant)).
var manager = ConfigManager.Create(c => c.UseConfiguration(rules =>
[
// Global base — applies to everything, injectable as usual:
rules.For<SmtpSettings>().FromStaticJson(smtpDefaults),
// Per-tenant overlay — wins per key, inherits the rest. The id flows via the accessor:
rules.For<SmtpSettings>().FromFile(a => $"tenants/{a.Tenant}/smtp.json").TenantScoped(),
]));The effective value for a tenant is [global rules] ++ [tenant-scoped rules], run through the same recompute/merge pipeline as any config — so transforms, required-rule rollback and dependency ordering all behave identically. Placing a global rule after the tenant overlay makes it a non-negotiable platform ceiling (it wins over the tenant) — no special tier, just list position.
Lifecycle
The host owns the tenant list. A tenant's configuration is materialized on demand and async is confined to that init moment — reads stay synchronous, exactly like the global config.
var tenants = (ITenantConfigurationAccessor)manager; // ConfigManager implements it
await tenants.InitializeTenantAsync("acme"); // build the tenant pipeline (at tenant creation)
await tenants.EnsureTenantInitializedAsync("acme"); // idempotent warmup (e.g. request-start middleware)
bool ready = tenants.IsTenantInitialized("acme");
await tenants.RemoveTenantAsync("acme"); // dispose the tenant bundle (at tenant removal)InitializeTenantAsync is idempotent and safe under concurrency — a tenant is built exactly once.
Consuming a tenant's configuration
Tenant-scoped values are obtained by passing the tenant id, never by DI injection:
var smtp = manager.GetConfigForTenant<SmtpSettings>("acme"); // sync read
var live = manager.GetReactiveConfigForTenant<SmtpSettings>("acme"); // IReactiveConfig<T> for this tenant
var flags = manager.GetFeatureFlagsForTenant<BillingFlags>("acme");
var ents = manager.GetEntitlementsForTenant<PlanEntitlements>("acme");
var store = manager.GetWritableStoreForTenant<SmtpSettings>("acme"); // per-tenant write facadeNot DI-injectable — by design
A type whose every rule is .TenantScoped() has no global value. Injecting it into a long-lived (singleton) consumer would be a captive-dependency bug — it would freeze one tenant forever, since the container cannot know the runtime tenant. The DI planner therefore excludes purely tenant-scoped types from the global plan. A type that also has a global base rule stays injectable (its base value is a valid global config). Consuming services inject the ConfigManager / ITenantConfigurationAccessor and call …ForTenant(currentTenant).
Scoped per-request injection (DI)
So scoped/transient services don't have to thread the tenant id by hand, a scoped ITenantReactiveConfig<T> (in Cocoar.Configuration.DI) resolves the current tenant for you. It reads the tenant from a scoped ITenantContext and delegates to GetReactiveConfigForTenant<T>(tenant).
You don't hand-write an ITenantContext — point a resolver at whatever already knows the tenant:
// Register the scoped adapter:
builder.Services.AddCocoarTenantReactiveConfig();
// ...then point a resolver at your existing tenant service:
builder.Services.AddCocoarTenantResolver<ApplicationTenantService>(s => s.TenantId);
// ...or, for plain HTTP, at IHttpContextAccessor — no AspNetCore-specific API needed:
builder.Services.AddHttpContextAccessor();
builder.Services.AddCocoarTenantResolver<IHttpContextAccessor>(
a => a.HttpContext?.Request.RouteValues["tenant"]?.ToString());
// Ensure the tenant pipeline is warm before it is consumed (e.g. request-start middleware):
app.Use(async (ctx, next) =>
{
if (ctx.Request.RouteValues["tenant"] is string t)
await app.Services.GetRequiredService<ConfigManager>().EnsureTenantInitializedAsync(t);
await next();
});
// In any scoped/transient service — no tenant id threaded by hand:
public sealed class SmtpSender(ITenantReactiveConfig<SmtpSettings> smtp)
{
public void Send() => Connect(smtp.CurrentValue.Host); // this request's tenant
}The selector is re-evaluated on every access, so the tenant can become known after the scope starts (e.g. post-auth-middleware). The singleton IReactiveConfig<T> is untouched — it stays the global view, so singletons keep working. A singleton that needs a specific tenant still calls GetReactiveConfigForTenant<T>(id) explicitly (it has no ambient request tenant).
Without DI there is no ambient scope to resolve from — pass the tenant explicitly with the …ForTenant(id) methods.
Feature flags & entitlements per tenant
The same source-generated flag/entitlement class is constructed with the tenant's IReactiveConfig<T>, so it evaluates against that tenant's effective config — no source-generator change:
public partial class BillingFlags : IFeatureFlags<BillingConfig>
{
public FeatureFlag<bool> PremiumEnabled => () => Config.PremiumBilling;
}
bool premium = manager.GetFeatureFlagsForTenant<BillingFlags>("acme").PremiumEnabled();In ASP.NET Core, map the tenant-dimensioned REST endpoints (a {tenant} route segment; the handler warms the tenant up and evaluates per tenant):
app.MapTenantFeatureFlagEndpoints(); // GET /tenants/{tenant}/flags/{FlagClass}/{FlagName}
app.MapTenantEntitlementEndpoints(); // GET /tenants/{tenant}/entitlements/{Class}/{Name}Per-tenant WritableStore
Give each tenant its own backend via the factory overload (the store is keyed by accessor.Tenant), and write through the per-tenant facade:
rules.For<SmtpSettings>().FromStore((a, _) => BackendFor(a.Tenant)).TenantScoped()
await manager.GetWritableStoreForTenant<SmtpSettings>("acme").SetAsync(x => x.Port, 587);A write triggers only that tenant's recompute; other tenants are untouched. Provenance (DescribeAsync) is computed over the tenant's own layers.
DB-backed config per tenant
When the per-tenant source is a database (Marten / EF) reached through a DI-managed store, use FromStore((sp, a) => …).TenantScoped() — the tenant gate and the service-provider gate compose, so the rule runs only inside a tenant pipeline, after the host has started. See Service-Backed Configuration.
Per-tenant secrets
Per-tenant secrets reuse the existing multi-kid certificate folder — kid = tenant. Lay certificates out as certsRoot/{tenant}/cert.pfx and each tenant's overlay carries an envelope tagged with its own kid:
c.UseSecretsSetup(secrets => secrets.UseCertificatesFromFolder(certsRoot));
using var lease = manager.GetConfigForTenant<VaultConfig>("acme").ApiKey!.Open(); // decrypts via certsRoot/acmeA tenant decrypts its own secret with its own certificate; it cannot decrypt another tenant's.
Fan-out: global changes reach tenants automatically
Each tenant pipeline runs the full rule list with its own provider subscriptions, so a change to a live global base source (file / observable / HTTP) propagates to every initialized tenant on its own debounced recompute and re-emits on that tenant's IReactiveConfig<T>. A tenant that masks the changed key with its own override does not emit. No coordinator to configure; consistency is per-tenant eventual (a global change lands tenant-by-tenant as each rebuild finishes).
Tuples across tenant scopes
A ValueTuple mixing a global-only type and a tenant-overridable one is fully supported — each element is read from the relevant pipeline's atomic snapshot. The global accessor skips .TenantScoped() overlays (you get base values); the per-tenant accessor gives effective values. The same holds for tuple-typed IFeatureFlags / IEntitlements. The one case that errors is a global tuple containing a type whose every rule is .TenantScoped() — it has no global value, so read it per tenant (GetReactiveConfigForTenant<…>(id)). ("Scope" is a property of a rule, not of a type — a type can carry both global and tenant-scoped rules.)
Limits in this version
- Resource use scales linearly with initialized tenants × base rules (each tenant re-runs the base). Acceptable for a host-bounded active-tenant set; a shared seed-from-global optimization is a future, API-compatible change.
- Eviction is explicit (
RemoveTenantAsync) only — no idle eviction.