Zonit.Extensions.Tenants
10.0.0-preview.10
See the version list below for details.
dotnet add package Zonit.Extensions.Tenants --version 10.0.0-preview.10
NuGet\Install-Package Zonit.Extensions.Tenants -Version 10.0.0-preview.10
<PackageReference Include="Zonit.Extensions.Tenants" Version="10.0.0-preview.10" />
<PackageVersion Include="Zonit.Extensions.Tenants" Version="10.0.0-preview.10" />
<PackageReference Include="Zonit.Extensions.Tenants" />
paket add Zonit.Extensions.Tenants --version 10.0.0-preview.10
#r "nuget: Zonit.Extensions.Tenants, 10.0.0-preview.10"
#:package Zonit.Extensions.Tenants@10.0.0-preview.10
#addin nuget:?package=Zonit.Extensions.Tenants&version=10.0.0-preview.10&prerelease
#tool nuget:?package=Zonit.Extensions.Tenants&version=10.0.0-preview.10&prerelease
Zonit.Extensions.Tenants
Per-domain site identity ("tenant") plus its persisted, strongly-typed settings.
Framework-agnostic: this package has no ASP.NET Core dependency — it references only
Microsoft.Extensions.DependencyInjection.Abstractions and
Zonit.Extensions. The HTTP glue (TenantMiddleware) lives in
Zonit.Extensions.Website, is internal, and is installed by app.UseWebsite<TApp>(...).
dotnet add package Zonit.Extensions.Tenants
What you get
ITenantSource(you implement it) —Task<Tenant?> GetByDomainAsync(string domain, CancellationToken ct = default). You receive the rawHttpRequest.Host.Host; you own case folding, aliases, wildcards and all cross-request caching. The framework never comparesTenant.Domainagainst anything.ITenantRepository(scoped) —Tenant? Current,void Initialize(Tenant?),Task<Tenant?> InitializeAsync(string domain, CancellationToken),event Action? OnChange. Per-scope snapshot; the middleware drives it in web hosts, you drive it everywhere else.ITenantProvider(scoped, read) —Tenant? Current,TenantSettings Settings,TSetting GetSetting<TSetting>() where TSetting : ISetting, new(),event Action? OnChange,event Action<SettingHydrationFailure>? OnSettingHydrationFailed.Tenant— a sealed class (not a record):required Guid Id,required string Domain,FrozenDictionary<string,string> Variables, the sentinelTenant.Solo(Guid.Empty/"*") andbool IsSolo.Setting<TModel>— abstract base for a setting:Key/Name/Description/Value/Templatesplus an AOT-safeTModel Hydrate(string json)you implement.- Four built-ins —
SiteSetting(site),ThemeSetting(theme),MaintenanceSetting(maintenance),SocialMediaSetting(social_media).MaintenanceSettingstores a flag; nothing in this package acts on it. TenantsJsonContext(public) — the source-generatedJsonTypeInfos for the four built-in models. Use it on the write side so the blobs you persist match whatHydratereads.- A Roslyn generator (
analyzers/dotnet/cs) that gives everySetting<T>you declare a strongly-typed accessor.
Setup
using Zonit.Extensions; // AddTenantsExtension() — namespace Zonit.Extensions
using Zonit.Extensions.Tenants; // ITenantProvider, ITenantSource, Tenant
builder.Services.AddScoped<ITenantSource, MyTenantSource>();
// AddWebsite() already calls AddTenantsExtension(); call it directly only in non-web hosts.
builder.Services.AddTenantsExtension();
AddTenantsExtension() TryAdds exactly two scoped services (ITenantRepository,
ITenantProvider) and is idempotent. It registers no ITenantSource — a single-site app leaves
that seam empty and the middleware seeds Tenant.Solo; a multi-site app registers its own. If a host
resolves the seam directly, use GetService<ITenantSource>(), because GetRequiredService throws
when none is registered.
Without middleware (console, worker, tests) drive the repository yourself:
using var scope = provider.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<ITenantRepository>();
await repository.InitializeAsync("acme.example.com"); // or repository.Initialize(Tenant.Solo);
Reading settings
@inject ITenantProvider Tenants
<h1>@Tenants.Settings.Site.Title</h1>
<style>:root { --primary: @Tenants.Settings.Theme.PrimaryColor; }</style>
Settings.Site returns the model (SiteSettingsModel). GetSetting<SiteSetting>() returns the
setting — add .Value for the equivalent expression. Both resolve to the same instance, cached
per scope and invalidated on OnChange. Treat the models as read-only: mutating one changes what
every later reader in the scope sees and persists nothing.
The storage contract
Tenant.Variables maps ISetting.Key → JSON of the model. The shape is whatever the setting's
Hydrate deserialises with; for the built-ins that is TenantsJsonContext, which uses camelCase
and serialises enums as numbers.
var json = JsonSerializer.Serialize(
new SiteSettingsModel { Title = "Acme", Language = "en-US" },
TenantsJsonContext.Default.SiteSettingsModel);
// {"title":"Acme","metaDescription":"This is a new website created","language":"en-US"}
Deserialisation is case-sensitive: a PascalCase blob (what a plain JsonSerializer.Serialize(model)
produces) matches nothing, hydrates the compile-time defaults, and raises no exception and no
event. Malformed JSON is different — it surfaces on ITenantProvider.OnSettingHydrationFailed as
SettingHydrationFailure(Guid TenantId, string SettingKey, Exception Exception). This package has no
ILogger dependency, so subscribing and forwarding to your logger is the only way to see it.
Your own setting
namespace Acme.Pricing;
public sealed class PricingModel { public string Plan { get; set; } = "free"; }
public sealed class PricingSetting : Setting<PricingModel>
{
public override string Key => "acme_pricing";
public override string Name => "Pricing";
public override string Description => "Billing plan shown on the pricing page.";
public override PricingModel Hydrate(string json)
=> JsonSerializer.Deserialize(json, PricingJsonContext.Default.PricingModel) ?? new();
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(PricingModel))]
public sealed partial class PricingJsonContext : JsonSerializerContext;
The generator emits a TenantSettingsExtensions class into Acme.Pricing, so with
using Acme.Pricing; you call Tenants.Settings.Pricing() — an extension method, because a
partial class cannot span assemblies. The four built-ins are properties because they are compiled
inside this package. GetSetting<PricingSetting>().Value works everywhere and needs no using.
The setting type must be public (non-public types are skipped) and Hydrate must use a
source-generated JsonSerializerContext — the reflection overloads break trimming and AOT.
Lifetimes
| Service | Lifetime | Notes |
|---|---|---|
ITenantRepository |
Scoped | Per-scope snapshot, no cross-request cache |
ITenantProvider |
Scoped | Caches hydrated settings per scope |
ITenantSource |
yours, Scoped recommended |
Not registered by the package — put your durable cache here |
AOT / trimming
The package is IsTrimmable / IsAotCompatible and contains no [UnconditionalSuppressMessage],
no [RequiresUnreferencedCode] and no [DynamicallyAccessedMembers]. Hydration is dispatched to
each concrete Setting<T>.Hydrate, so AOT-safety of a custom setting is the author's responsibility.
Known limitations
- Interactive Blazor circuits have no tenant. The middleware only runs for HTTP request scopes,
so
Currentisnullin a circuit and settings render their compile-time defaults after prerender.TenantSnapshot(From/ToTenant) exists as the payload for a prerender→circuit bridge, but that bridge is not implemented yet and nothing in the SDK consumes the type. - Colliding accessor names break the build. Two settings in one namespace that reduce to the same
accessor (
FooSettingandFoo) emit duplicate extension methods →CS0111in generated code. Rename one, or move it to another namespace.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.2)
- Zonit.Extensions (>= 10.0.0-preview.10)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Zonit.Extensions.Tenants:
| Package | Downloads |
|---|---|
|
Zonit.Extensions.Website
ASP.NET Core and Blazor web extensions providing base components (PageBase, PageEditBase, PageViewBase), navigation services, breadcrumbs management, toast notifications, cookie handling, and data protection utilities for building modern web applications. |
|
|
Zonit.Extensions.Tenants.Web
Package Description |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 10.0.0-preview.27 | 96 | 8/17/2026 |
| 10.0.0-preview.26 | 184 | 8/14/2026 |
| 10.0.0-preview.25 | 96 | 8/14/2026 |
| 10.0.0-preview.24 | 101 | 8/12/2026 |
| 10.0.0-preview.23 | 88 | 8/11/2026 |
| 10.0.0-preview.22 | 87 | 8/11/2026 |
| 10.0.0-preview.21 | 88 | 8/11/2026 |
| 10.0.0-preview.20 | 84 | 8/10/2026 |
| 10.0.0-preview.19 | 84 | 8/10/2026 |
| 10.0.0-preview.18 | 87 | 8/9/2026 |
| 10.0.0-preview.17 | 91 | 8/9/2026 |
| 10.0.0-preview.16 | 99 | 8/9/2026 |
| 10.0.0-preview.15 | 88 | 8/7/2026 |
| 10.0.0-preview.14 | 83 | 8/6/2026 |
| 10.0.0-preview.13 | 85 | 8/6/2026 |
| 10.0.0-preview.12 | 90 | 8/6/2026 |
| 10.0.0-preview.11 | 108 | 8/4/2026 |
| 10.0.0-preview.10 | 94 | 8/3/2026 |
| 10.0.0-preview.9 | 92 | 5/16/2026 |
| 1.0.50 | 561 | 11/18/2025 |