Tired of connection strings breaking between dev/test/prod? IOptions pattern with validation ensures your app never starts with wrong config.
The Configuration Nightmare:
// The old way - scattered magic strings
public class OrderService
{
private readonly string _connectionString;
public OrderService(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("DefaultConnection");
// What if configuration key doesn't exist?
// What if connection string format is wrong?
// What if required setting is missing?
}
}
// Multiple environments, multiple problems
// appsettings.Development.json
// appsettings.Staging.json
// appsettings.Production.json
// appsettings.Local.json
// Secrets.json
// Environment variables
// Azure Key Vault
// Which value wins? When does validation happen?
// Answer: TOO LATE - at runtime when service tries to use it!
Solution: Strongly Typed IOptions Pattern
// 1. Define configuration class with validation
public class DatabaseSettings
{
[Required(ErrorMessage = "Connection string is required")]
[RegularExpression("^Server=.+;Database=.+;User Id=.+;Password=.+;",
ErrorMessage = "Invalid connection string format")]
public string ConnectionString { get; set; }
[Range(1, 100, ErrorMessage = "Max connections must be between 1 and 100")]
public int MaxConnections { get; set; } = 20;
[Range(1, 300, ErrorMessage = "Timeout must be between 1 and 300 seconds")]
public int CommandTimeout { get; set; } = 30;
public bool EnableSensitiveDataLogging { get; set; }
}
public class EmailSettings
{
[Required]
public string SmtpServer { get; set; }
[Range(1, 65535)]
public int Port { get; set; } = 587;
[Required]
public string Username { get; set; }
public string Password { get; set; }
public bool EnableSsl { get; set; } = true;
[Range(1, 100)]
public int RetryCount { get; set; } = 3;
}
// 2. Register with validation in Program.cs
builder.Services.AddOptions()
.Bind(builder.Configuration.GetSection("Database"))
.ValidateDataAnnotations() // Automatic validation
.Validate(settings =>
{
// Custom validation logic
return !settings.ConnectionString.Contains("localhost")
|| builder.Environment.IsDevelopment();
}, "Cannot use localhost in production!")
.ValidateOnStart(); // Critical: Validate at startup, not runtime!
builder.Services.AddOptions()
.Bind(builder.Configuration.GetSection("Email"))
.ValidateDataAnnotations()
.ValidateOnStart();
Usage with Dependency Injection:
// Service using IOptionspublic class OrderService { private readonly DatabaseSettings _dbSettings; private readonly ILogger _logger; // Inject IOptions , not raw settings public OrderService(IOptions dbOptions, ILogger logger) { _dbSettings = dbOptions.Value; // Already validated! _logger = logger; // Safe to use - validation happened at startup using var connection = new SqlConnection(_dbSettings.ConnectionString); // No null/format checks needed here } } // Or use IOptionsSnapshot for scoped settings (changes per request) public class ScopedService { private readonly EmailSettings _emailSettings; public ScopedService(IOptionsSnapshot emailOptions) { _emailSettings = emailOptions.Value; // IOptionsSnapshot reloads config if it changes // Perfect for feature flags that change at runtime } } // Or IOptionsMonitor for change notifications public class NotificationService : IDisposable { private readonly IOptionsMonitor _emailMonitor; private readonly IDisposable _changeListener; public NotificationService(IOptionsMonitor emailMonitor) { _emailMonitor = emailMonitor; // Listen for configuration changes _changeListener = _emailMonitor.OnChange(settings => { Console.WriteLine($"Email settings changed at {DateTime.UtcNow}"); // Reconfigure email client with new settings ReconfigureEmailClient(settings); }); } public void Dispose() => _changeListener?.Dispose(); }
Advanced: Configuration Source Chain
// Program.cs - Complete setup
var builder = WebApplication.CreateBuilder(args);
// 1. Base appsettings.json (lowest priority)
// 2. appsettings.{Environment}.json (overrides base)
// 3. User secrets (development only)
// 4. Environment variables (overrides JSON)
// 5. Command line arguments (highest priority)
// 6. Azure Key Vault (production secrets)
builder.Configuration
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json",
optional: true, reloadOnChange: true);
if (builder.Environment.IsDevelopment())
{
builder.Configuration.AddUserSecrets();
}
builder.Configuration.AddEnvironmentVariables();
if (args != null)
{
builder.Configuration.AddCommandLine(args);
}
// Azure Key Vault for production
if (builder.Environment.IsProduction())
{
var keyVaultUrl = builder.Configuration["AzureKeyVault:Url"];
if (!string.IsNullOrEmpty(keyVaultUrl))
{
builder.Configuration.AddAzureKeyVault(
new Uri(keyVaultUrl),
new DefaultAzureCredential());
}
}
// Now register options with this layered configuration
builder.Services.AddOptions()
.Bind(builder.Configuration.GetSection("Database"))
.ValidateDataAnnotations()
.ValidateOnStart();
// The magic: Settings are resolved in this order:
// 1. Command line: --Database:ConnectionString "Server=prod;..."
// 2. Environment: DATABASE__CONNECTIONSTRING (note double underscore)
// 3. User secrets: Database:ConnectionString
// 4. appsettings.Production.json
// 5. appsettings.json
Real-World Configuration Example:
// appsettings.Production.json
{
"Database": {
"ConnectionString": "Server=production-db.database.windows.net;...",
"MaxConnections": 50,
"CommandTimeout": 60,
"EnableSensitiveDataLogging": false
},
"Email": {
"SmtpServer": "smtp.sendgrid.net",
"Port": 587,
"Username": "apikey",
"EnableSsl": true,
"RetryCount": 5
},
"FeatureFlags": {
"EnableNewCheckout": true,
"EnableDarkMode": false,
"MaintenanceMode": false
},
"ExternalApis": {
"PaymentGateway": {
"Url": "https://api.payment.com/v2",
"ApiKey": "",
"TimeoutSeconds": 30
},
"SmsService": {
"Url": "https://api.twilio.com",
"AccountSid": "",
"AuthToken": ""
}
}
}
// appsettings.Development.json - overrides Production settings
{
"Database": {
"ConnectionString": "Server=(localdb)\\mssqllocaldb;Database=MyApp_Dev;Trusted_Connection=True;",
"EnableSensitiveDataLogging": true // See parameter values in logs
},
"FeatureFlags": {
"EnableNewCheckout": false // Disable in dev
}
}
// secrets.json (never committed!)
{
"Email:Password": "SG.abc123...",
"ExternalApis:PaymentGateway:ApiKey": "pk_test_456...",
"ExternalApis:SmsService:AccountSid": "AC123...",
"ExternalApis:SmsService:AuthToken": "xyz789..."
}
