guarantees, deterministic DI lifetimes, and explicit failure modes.
Core Solution
Implementing a production-grade configuration pattern requires three layers: strongly-typed options, validation at registration, and DI-bound consumption. The following implementation targets .NET 8+ and leverages Microsoft.Extensions.Options and Microsoft.Extensions.Configuration.
Step 1: Define Strongly-Typed Options Classes
Options classes must be plain C# objects with explicit property types. Avoid inheritance hierarchies; composition scales better.
public sealed class DatabaseOptions
{
public string ConnectionString { get; init; } = string.Empty;
public int CommandTimeoutSeconds { get; init; } = 30;
public bool EnableRetryLogic { get; init; } = false;
}
public sealed class CacheOptions
{
public string RedisConnectionString { get; init; } = string.Empty;
public int DefaultExpirationMinutes { get; init; } = 60;
public bool UseCompression { get; init; } = true;
}
Register options using IOptionsSnapshot for scoped consumption or IOptionsMonitor for singleton hot-reload. Attach validation at startup to fail fast.
builder.Services.AddOptions<DatabaseOptions>()
.BindConfiguration("Database")
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddOptions<CacheOptions>()
.BindConfiguration("Cache")
.ValidateDataAnnotations()
.ValidateOnStart();
For complex validation rules, use FluentValidation:
builder.Services.AddOptions<DatabaseOptions>()
.BindConfiguration("Database")
.Validate(new DatabaseOptionsValidator())
.ValidateOnStart();
Step 3: Wire External Providers in Program.cs
Configuration providers are additive. Order matters: later providers override earlier ones.
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args)
.AddAzureKeyVault(
new Uri(builder.Configuration["KeyVault:Uri"]!),
new DefaultAzureCredential());
Step 4: Consume via Constructor Injection
Never resolve IConfiguration in business services. Inject IOptions<T> or IOptionsMonitor<T>.
public sealed class UserRepository
{
private readonly DatabaseOptions _dbOptions;
private readonly ILogger<UserRepository> _logger;
public UserRepository(IOptions<DatabaseOptions> dbOptions, ILogger<UserRepository> logger)
{
_dbOptions = dbOptions.Value;
_logger = logger;
}
public async Task<User?> GetByIdAsync(Guid id, CancellationToken ct)
{
// _dbOptions.ConnectionString is guaranteed non-null and validated
using var conn = new SqlConnection(_dbOptions.ConnectionString);
// ...
}
}
Architecture Decisions & Rationale
IOptionsSnapshot vs IOptionsMonitor: Use IOptionsSnapshot for scoped services where configuration is read once per request. Use IOptionsMonitor for singletons that require hot-reload without application restarts. IOptionsMonitor caches values and fires OnChange events, keeping memory overhead near zero.
- Validation at Registration:
ValidateOnStart() throws OptionsValidationException during Build(). This prevents silent degradation and fails deployments before traffic reaches the service.
- Explicit Binding:
BindConfiguration("Section") isolates configuration scopes. Avoid binding entire IConfiguration to a single options class; it creates hidden dependencies and breaks environment isolation.
- Immutability: Use
init properties to prevent runtime mutation. Configuration should be treated as read-only after startup.
Pitfall Guide
-
Static Configuration Access
Accessing ConfigurationManager.AppSettings or storing IConfiguration in static fields breaks DI, prevents testing, and bypasses validation. Configuration must flow through the DI container.
-
Ignoring Validation Until Runtime
Skipping ValidateOnStart() defers failures to production. Missing keys, type mismatches, and invalid ranges will surface as NullReferenceException or FormatException under load. Always validate at startup.
-
Treating IOptionsSnapshot as Singleton
IOptionsSnapshot is scoped. Injecting it into a singleton service causes OptionsValidationException at runtime. Use IOptionsMonitor<T> for singletons, or restructure the service to scoped lifetime.
-
Over-Engineering Custom IConfigurationProvider
Writing a custom provider for simple JSON or environment variable scenarios adds maintenance burden without value. Use built-in providers (AddJsonFile, AddEnvironmentVariables, AddAzureKeyVault) unless you need protocol-specific parsing (e.g., HashiCorp Vault, etcd).
-
Mixing Configuration Loading with Business Initialization
Placing builder.Configuration.Add... inside service registration methods or controller constructors violates separation of concerns. Configuration must be fully resolved before builder.Services.BuildServiceProvider() completes.
-
Assuming Configuration is Immutable at Startup
While options classes should be immutable, the underlying configuration tree can change. If you cache IConfiguration["Key"] in a static field, you lose hot-reload capability. Always inject options, never cache raw configuration strings.
-
Not Handling Optional Keys Gracefully
Marking a key as optional but failing to provide a fallback causes runtime nulls. Use init with defaults, or apply [Required] only when the key is mandatory. Validate defaults in unit tests.
Production Best Practices:
- Keep options classes focused. One section per class.
- Use
IOptionsMonitor for dynamic thresholds (rate limits, feature flags).
- Document expected configuration keys in a
README or OpenAPI extension.
- Never store secrets in
appsettings.json committed to source control. Use user secrets for dev, Key Vault/Secrets Manager for prod.
- Test configuration binding explicitly:
services.Configure<T>(config.GetSection("Section")) in integration tests.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Static connection strings, infrequent changes | IOptionsSnapshot + DataAnnotations | Minimal overhead, compile-time safety, fast startup | Low |
| Dynamic rate limits, feature toggles, A/B configs | IOptionsMonitor + OnChange | Hot-reload without restart, zero-downtime updates | Medium |
| Multi-tenant SaaS with per-tenant configs | Custom IConfigurationProvider + IOptionsMonitor | Isolates tenant scopes, supports runtime reload | High |
| Legacy monolith migrating to .NET 8 | Manual POCO binding β Options Pattern migration | Phased approach reduces risk, preserves existing DI | Low-Medium |
| High-compliance environment (HIPAA/FedRAMP) | Azure Key Vault + ValidateOnStart() + Secret scanning | Enforces fail-fast, eliminates plaintext secrets | Medium |
Configuration Template
appsettings.json
{
"Database": {
"ConnectionString": "Server=localhost;Database=AppDb;TrustServerCertificate=true;",
"CommandTimeoutSeconds": 30,
"EnableRetryLogic": true
},
"Cache": {
"RedisConnectionString": "localhost:6379",
"DefaultExpirationMinutes": 60,
"UseCompression": true
}
}
Options Registration (Program.cs)
builder.Services.AddOptions<DatabaseOptions>()
.BindConfiguration("Database")
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddOptions<CacheOptions>()
.BindConfiguration("Cache")
.ValidateDataAnnotations()
.ValidateOnStart();
Consumption Pattern
public sealed class CacheService
{
private readonly CacheOptions _cacheOptions;
private readonly IOptionsMonitor<CacheOptions> _monitor;
public CacheService(IOptions<CacheOptions> cacheOptions, IOptionsMonitor<CacheOptions> monitor)
{
_cacheOptions = cacheOptions.Value;
_monitor = monitor;
_monitor.OnChange(opts =>
_logger.LogInformation("Cache config updated: Expiration={Minutes}", opts.DefaultExpirationMinutes));
}
}
Quick Start Guide
- Install required packages:
dotnet add package Microsoft.Extensions.Options.DataAnnotations
- Create
Options/DatabaseOptions.cs and Options/CacheOptions.cs with init properties and defaults
- Add registration blocks in
Program.cs using AddOptions<T>().BindConfiguration().ValidateDataAnnotations().ValidateOnStart()
- Replace direct
IConfiguration usage in services with constructor-injected IOptions<T> or IOptionsMonitor<T>
- Run
dotnet build && dotnet run to verify ValidateOnStart() catches missing/invalid keys before the first HTTP request