Tenant stores
A tenant store answers "which tenants exist, and what are their details?" The resolution middleware
calls it with a parsed TKey and expects back an ITenantDescriptor<TKey> (or null if there is no
such tenant). It is also where you enforce that a tenant is active/not suspended.
public interface ITenantStore<TKey>
{
ValueTask<ITenantDescriptor<TKey>?> GetTenantAsync(TKey tenantId, CancellationToken ct = default);
ValueTask<IReadOnlyList<ITenantDescriptor<TKey>>> GetAllTenantsAsync(CancellationToken ct = default);
}Exactly one store must be registered. AddTenantry validates this at startup and throws a clear
InvalidOperationException if no store is registered. (AddTenantryCore does not validate, because a
non-HTTP host may create every scope from a descriptor it already holds and never need a store — see
Non-HTTP hosts. ITenantStoreAccessor and ITenantScopeFactory.RunInScopeAsync do
need one, and say so if it is missing.)
Singletons such as hosted services should read tenants through ITenantStoreAccessor<TKey>, which
resolves the store from a fresh scope on each call, rather than injecting a scoped store directly.
In-memory store
For tests, demos, and simple single-instance deployments where tenants do not change at runtime:
tenant.UseInMemoryStore(
[
new TenantDescriptor<Guid> { TenantId = acmeId, Name = "Acme" },
new TenantDescriptor<Guid> { TenantId = globexId, Name = "Globex" },
]);This registers InMemoryTenantStore<TKey> as a singleton. The collection is indexed by TenantId
into a dictionary once, so lookups are O(1). It does not observe changes to the source collection
after registration.
Custom store
For anything real — tenants in a database, a cache, a config service — implement ITenantStore<TKey>.
public sealed class EfCoreTenantStore(AppDbContext db, ILogger<EfCoreTenantStore> logger)
: ITenantStore<string>
{
public async ValueTask<ITenantDescriptor<string>?> GetTenantAsync(string tenantId, CancellationToken ct = default)
{
var tenant = await db.Tenants.AsNoTracking()
.FirstOrDefaultAsync(t => t.TenantId == tenantId, ct);
if (tenant is null) return null;
// The store is the right place to reject suspended/inactive tenants — return null and the
// middleware responds 404 (tenant not found), never opening a scope for them.
if (!tenant.IsActive)
{
logger.LogWarning("Tenant {TenantId} is inactive", tenantId);
return null;
}
return tenant; // any ITenantDescriptor<string> implementation
}
public async ValueTask<IReadOnlyList<ITenantDescriptor<string>>> GetAllTenantsAsync(CancellationToken ct = default) =>
await db.Tenants.AsNoTracking().Where(t => t.IsActive)
.ToListAsync<ITenantDescriptor<string>>(ct);
}Register it one of three ways:
// 1. By type — resolved from DI, registered as Scoped.
tenant.UseStore<EfCoreTenantStore>();
// 2. By factory — also Scoped; use when you need IServiceProvider to construct it.
tenant.UseStore(sp => new EfCoreTenantStore(sp.GetRequiredService<AppDbContext>(), sp.GetRequiredService<ILogger<EfCoreTenantStore>>()));Lifetimes.
UseInMemoryStoreregisters a singleton;UseStore<T>()andUseStore(factory)register scoped. Scoped is the right default for stores that depend on a scopedDbContext: the resolution middleware resolves the store from the request's service scope, so a store sharing the request'sDbContextworks correctly. If your store is stateless and cheap, that is fine; if it does a database round-trip per request and you want caching, add anIMemoryCache/HybridCachelayer inside your implementation.
Bootstrapping with an EF Core-backed store
There is a chicken-and-egg consideration if your tenant registry lives in the same database your
tenanted entities do: the Tenant table itself must not be a tenanted entity (do not make it
implement ITenantScoped<TKey>), or the query filter would prevent the store from reading it before a
tenant is resolved. Keep the tenant registry global. See the
EfCoreWeb sample for a complete example with a Tenant
entity, an EfCoreTenantStore, and seeded data.
Caching considerations
The middleware calls GetTenantAsync once per request. If that is a database hit you would rather not
take every request, cache inside your store implementation — Tenantry deliberately does not impose a
caching strategy. Remember to invalidate on tenant changes (rename, suspend, delete).