Options Pattern - V2
Reading Time: 6 minutes
Why a V2?
The baseline Options Pattern is great for single-tenant apps with one configuration shape. But in real systems, requirements often include:
- Multiple providers (for example,
SendGrid,Smtp,MailKit) in the same app - Per-tenant settings with tenant-specific overrides
- Runtime updates without restarts
- Strong validation and safe defaults across all variants
This post dives into advanced patterns to solve those scenarios cleanly.
1. Named Options: Multiple Config Variants of the Same Type
Named options let you keep one options type and bind multiple named instances.
Example use case
You may need two email pipelines:
Transactionalemail (password reset, verification)Marketingemail (campaigns, digests)
Both share the same shape, but values differ.
Options model
public sealed class EmailOptions
{
public string Provider { get; set; } = "SendGrid";
public string ApiKey { get; set; } = string.Empty;
public string FromAddress { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; } = 30;
}
appsettings.json
{
"Email": {
"Transactional": {
"Provider": "SendGrid",
"ApiKey": "txn-key",
"FromAddress": "[email protected]",
"TimeoutSeconds": 15
},
"Marketing": {
"Provider": "SendGrid",
"ApiKey": "mkt-key",
"FromAddress": "[email protected]",
"TimeoutSeconds": 60
}
}
}
Registration
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<EmailOptions>("Transactional")
.Bind(builder.Configuration.GetSection("Email:Transactional"))
.Validate(o => !string.IsNullOrWhiteSpace(o.ApiKey), "Transactional ApiKey is required")
.ValidateOnStart();
builder.Services
.AddOptions<EmailOptions>("Marketing")
.Bind(builder.Configuration.GetSection("Email:Marketing"))
.Validate(o => !string.IsNullOrWhiteSpace(o.ApiKey), "Marketing ApiKey is required")
.ValidateOnStart();
Consumption with IOptionsSnapshot
public sealed class CampaignService
{
private readonly IOptionsSnapshot<EmailOptions> _email;
public CampaignService(IOptionsSnapshot<EmailOptions> email)
{
_email = email;
}
public Task SendDigestAsync()
{
var options = _email.Get("Marketing");
// Use Marketing config
return Task.CompletedTask;
}
public Task SendPasswordResetAsync()
{
var options = _email.Get("Transactional");
// Use Transactional config
return Task.CompletedTask;
}
}
Named options tips
- Use constants for names (
public const string Transactional = "Transactional") to avoid string typos. - Keep names business-oriented, not technical.
- Validate each named option independently.
- Prefer
IOptionsSnapshot<T>in request pipelines andIOptionsMonitor<T>in long-running services.
2. PostConfigure and ConfigureAll for Safe Defaults
As systems evolve, configuration sections can become inconsistent across environments.
Use PostConfigure and ConfigureAll to enforce global rules.
builder.Services.ConfigureAll<EmailOptions>(o =>
{
if (o.TimeoutSeconds <= 0)
{
o.TimeoutSeconds = 30;
}
});
builder.Services.PostConfigure<EmailOptions>("Marketing", o =>
{
// Example: enforce a longer timeout for marketing provider calls
if (o.TimeoutSeconds < 30)
{
o.TimeoutSeconds = 30;
}
});
This helps harden behavior without duplicating logic in every caller.
3. Multi-Tenant Configuration Binding
Named options solve “many variants” globally. Multi-tenant systems add a harder dimension: per-tenant config selection at runtime.
Typical requirement
Each tenant can override:
- API keys
- Feature flags
- throttling limits
- provider selection
while still inheriting shared defaults.
4. Pattern A: Tenant Dictionary + Resolver Service (Simple, Effective)
Bind all tenant configs once and resolve per request using current tenant context.
appsettings.json
{
"Tenants": {
"acme": {
"Email": {
"Provider": "SendGrid",
"ApiKey": "acme-key",
"FromAddress": "[email protected]",
"TimeoutSeconds": 20
}
},
"globex": {
"Email": {
"Provider": "Smtp",
"ApiKey": "globex-secret",
"FromAddress": "[email protected]",
"TimeoutSeconds": 45
}
}
}
}
Models
public sealed class TenantCatalogOptions
{
public Dictionary<string, TenantConfig> Tenants { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
public sealed class TenantConfig
{
public EmailOptions Email { get; set; } = new();
}
Registration
builder.Services
.AddOptions<TenantCatalogOptions>()
.Bind(builder.Configuration)
.Validate(o => o.Tenants.Count > 0, "At least one tenant must be configured")
.ValidateOnStart();
Tenant context abstraction
public interface ITenantContextAccessor
{
string TenantId { get; }
}
Resolver
public interface ITenantEmailOptionsResolver
{
EmailOptions GetCurrentTenantEmailOptions();
}
public sealed class TenantEmailOptionsResolver : ITenantEmailOptionsResolver
{
private readonly ITenantContextAccessor _tenantContext;
private readonly IOptionsSnapshot<TenantCatalogOptions> _catalog;
public TenantEmailOptionsResolver(
ITenantContextAccessor tenantContext,
IOptionsSnapshot<TenantCatalogOptions> catalog)
{
_tenantContext = tenantContext;
_catalog = catalog;
}
public EmailOptions GetCurrentTenantEmailOptions()
{
var tenantId = _tenantContext.TenantId;
if (!_catalog.Value.Tenants.TryGetValue(tenantId, out var tenantConfig))
{
throw new InvalidOperationException($"No Email configuration found for tenant '{tenantId}'.");
}
return tenantConfig.Email;
}
}
This pattern is explicit and test-friendly. For many applications, it is enough.
5. Pattern B: Tenant-Aware Named Options (Advanced)
You can combine tenant identity with named options using a name format like:
"tenant:acme""tenant:globex"
Then resolve via IOptionsMonitor<EmailOptions>.Get(name).
This pattern is powerful when:
- You already use named options heavily
- You want one unified retrieval model
- You need monitor-based updates in background workers
But it introduces complexity around naming, invalidation, and fallback behavior. If your team is not already comfortable with advanced options internals, Pattern A is usually safer.
6. Fallback Composition: Global Defaults + Tenant Overrides
A common production strategy:
- Bind global defaults (
EmailDefaults) - Bind tenant partial overrides
- Merge at runtime
Example merge logic:
public static EmailOptions Merge(EmailOptions defaults, EmailOptions tenant)
{
return new EmailOptions
{
Provider = string.IsNullOrWhiteSpace(tenant.Provider) ? defaults.Provider : tenant.Provider,
ApiKey = string.IsNullOrWhiteSpace(tenant.ApiKey) ? defaults.ApiKey : tenant.ApiKey,
FromAddress = string.IsNullOrWhiteSpace(tenant.FromAddress) ? defaults.FromAddress : tenant.FromAddress,
TimeoutSeconds = tenant.TimeoutSeconds <= 0 ? defaults.TimeoutSeconds : tenant.TimeoutSeconds
};
}
This avoids duplicating every setting for every tenant and reduces config drift.
7. Validation Strategy for Multi-Tenant Options
Do not validate only the root object. Validate each tenant entry.
builder.Services
.AddOptions<TenantCatalogOptions>()
.Bind(builder.Configuration)
.Validate(o => o.Tenants.All(kvp =>
!string.IsNullOrWhiteSpace(kvp.Key) &&
!string.IsNullOrWhiteSpace(kvp.Value.Email.ApiKey) &&
!string.IsNullOrWhiteSpace(kvp.Value.Email.FromAddress)),
"Each tenant must define valid Email settings")
.ValidateOnStart();
For complex rules, create IValidateOptions<T> implementations so validation stays maintainable.
8. Tenant Isolation and Security Considerations
When resolving tenant options:
- Never allow a request to specify an arbitrary tenant name without authorization
- Derive tenant identity from trusted context (domain, token claims, mTLS, gateway header policy)
- Avoid logging raw secrets from options objects
- Keep secrets in a secure store (for example, Key Vault) and load references or secret names in config
9. Testing Patterns
Unit tests
- Mock
ITenantContextAccessorto simulate tenant identity - Provide in-memory
IOptionsSnapshot<TenantCatalogOptions>values - Assert fallback and validation behavior
Integration tests
- Run with multiple tenants configured
- Verify request A cannot read tenant B settings
- Verify startup fails on invalid tenant config
10. When to Use Which Pattern
- Single tenant, one config shape: regular
IOptions<T> - Multiple variants, no tenant dimension: named options
- Per-tenant settings with moderate complexity: tenant dictionary + resolver (Pattern A)
- Highly dynamic runtime config with advanced team maturity: tenant-aware named options + monitor
Summary
Named options and multi-tenant binding are natural extensions of the Options Pattern once your architecture scales.
The most important production principles are:
- Keep option models small and focused
- Validate aggressively at startup
- Make tenant resolution explicit and trusted
- Prefer simple resolver patterns unless complexity is justified
Mastering these patterns lets you scale one codebase across many environments, providers, and tenants without turning configuration into technical debt.
References
- Options pattern in ASP.NET Core: https://learn.microsoft.com/aspnet/core/fundamentals/configuration/options
- Named options support: https://learn.microsoft.com/aspnet/core/fundamentals/configuration/options#named-options-support-using-iconfigurenamedoptions
IOptionsMonitor<T>API: https://learn.microsoft.com/dotnet/api/microsoft.extensions.options.ioptionsmonitor-1