Fixed Tenant Configuration Binding in .NET Using the Modern Options Pattern
When tenant/client identities are fixed in code, named options are a clean way to bind per-tenant settings from configuration. This avoids custom config loaders and keeps everything in the built-in .NET options system.
This guide shows a practical global + tenant configuration structure using modern options registration.
Why It Matters
- Keeps tenant configuration strongly typed and predictable.
- Uses built-in dependency injection and options features.
- Avoids manual dictionary parsing logic.
- Scales cleanly when fixed tenant list grows.
Core Concepts
1. Configuration Shape
Use separate sections for global settings and tenant-specific settings.
{
"GlobalSettings": {
"FeatureXEnabled": true,
"ApiEndpoint": "https://example.com"
},
"Clients": {
"ClientA": {
"ConnectionString": "Server=A",
"Region": "EU"
},
"ClientB": {
"ConnectionString": "Server=B",
"Region": "US"
}
}
}
2. Fixed Tenant Identifiers
Represent tenants as enum values for stable names.
public enum Client
{
ClientA,
ClientB
}
3. Strongly Typed Options Models
public sealed class GlobalSettings
{
public bool FeatureXEnabled { get; init; }
public string ApiEndpoint { get; init; } = string.Empty;
}
public sealed class ClientSettings
{
public string ConnectionString { get; init; } = string.Empty;
public string Region { get; init; } = string.Empty;
}
4. Global Options Registration
Bind and validate global section once.
builder.Services
.AddOptions<GlobalSettings>()
.Bind(builder.Configuration.GetSection("GlobalSettings"))
.Validate(settings => !string.IsNullOrWhiteSpace(settings.ApiEndpoint), "ApiEndpoint is required")
.ValidateOnStart();
5. Named Options per Tenant
Bind one named options instance per enum value.
foreach (Client client in Enum.GetValues<Client>())
{
var clientName = client.ToString();
builder.Services
.AddOptions<ClientSettings>(clientName)
.Bind(builder.Configuration.GetSection($"Clients:{clientName}"))
.Validate(settings => !string.IsNullOrWhiteSpace(settings.ConnectionString), $"{clientName} ConnectionString is required")
.Validate(settings => !string.IsNullOrWhiteSpace(settings.Region), $"{clientName} Region is required")
.ValidateOnStart();
}
6. Tenant Options Consumption
Resolve tenant settings through IOptionsSnapshot<T>.Get(name).
public sealed class ClientConsumer
{
private readonly IOptionsSnapshot<ClientSettings> _clientSettings;
public ClientConsumer(IOptionsSnapshot<ClientSettings> clientSettings)
{
_clientSettings = clientSettings;
}
public ClientSettings Get(Client client)
{
return _clientSettings.Get(client.ToString());
}
}
Practical Example
Minimal startup wiring:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<GlobalSettings>()
.Bind(builder.Configuration.GetSection("GlobalSettings"))
.ValidateOnStart();
foreach (Client client in Enum.GetValues<Client>())
{
var name = client.ToString();
builder.Services
.AddOptions<ClientSettings>(name)
.Bind(builder.Configuration.GetSection($"Clients:{name}"))
.ValidateOnStart();
}
var app = builder.Build();
app.Run();
This keeps tenant config deterministic and explicit. No magic, no hidden fallback surprises.
Common Mistakes
- Using free-form tenant names that drift from code identifiers.
- Skipping validation and discovering missing settings only at runtime.
- Mixing global and tenant settings in one model.
- Injecting
IConfigurationeverywhere instead of typed options. - Forgetting named options lookup (
Get(name)) for tenant config.
Quick Recap
- Fixed tenant list maps naturally to named options.
- Global config and tenant config should be split clearly.
- Use
ValidateOnStart()to fail fast on bad config. - Keep consumption through typed options, not manual parsing.
- This pattern is clean, testable, and production-friendly.
Next Steps
- Add per-tenant secret loading from secure vault providers.
- Add health checks that verify required tenant settings.
- Add integration tests for each configured tenant binding.
- Add migration path if tenants become dynamic in future.