Modern .NET applications rely heavily on configuration to adapt behavior across environments without code changes. While you can read configuration values directly from IConfiguration, the strongly-typed options pattern through IOptions<T> provides type safety, validation, and better maintainability. This guide explores the .NET configuration system in depth, from fundamentals to advanced production scenarios.
The .NET Configuration System
The .NET configuration system is built on a layered, provider-based architecture that merges settings from multiple sources into a unified view.
How Configuration Works
At its core, the configuration system uses ConfigurationBuilder to collect settings from various providers:
var builder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{env}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables()
.AddUserSecrets<Program>()
.AddCommandLine(args);
IConfiguration configuration = builder.Build();
Key Concepts:
- Provider Order Matters: Later providers override earlier ones
- Hierarchical Keys: Use
:(colon) to denote nesting (e.g.,Database:ConnectionString) - Multiple Sources: JSON files, environment variables, command-line arguments, Azure Key Vault, etc.
Default Configuration in ASP.NET Core
With the minimal hosting model in .NET 6+, much of this is preconfigured:
var builder = WebApplication.CreateBuilder(args);
// Configuration is already built with default providers:
// 1. appsettings.json
// 2. appsettings.{Environment}.json
// 3. User Secrets (Development only)
// 4. Environment Variables
// 5. Command-line arguments
var connectionString = builder.Configuration.GetConnectionString("Default");
Configuration Providers
Common configuration sources:
| Provider | Use Case | Example |
|---|---|---|
| JSON File | Application settings | appsettings.json |
| Environment Variables | Container/cloud deployment | DATABASE__HOST=localhost |
| User Secrets | Development credentials | dotnet user-secrets set "ApiKey" "abc123" |
| Command Line | Overrides at runtime | --Environment=Production |
| Azure Key Vault | Production secrets | Certificate-based connection |
| Azure App Configuration | Feature flags, dynamic config | Centralized configuration service |
Environment-Specific Configuration
// appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"Database": {
"ConnectionString": "Server=localhost;Database=Dev",
"MaxRetries": 3
}
}
// appsettings.Production.json
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft": "Error"
}
},
"Database": {
"ConnectionString": "Server=prod-server;Database=Prod",
"MaxRetries": 5
}
}
Why Strongly-Typed Configuration?
Reading configuration directly from IConfiguration works but has significant drawbacks:
The Problem with IConfiguration
// ❌ Directly reading configuration - fragile and error-prone
public class OrderService
{
private readonly IConfiguration _configuration;
public OrderService(IConfiguration configuration)
{
_configuration = configuration;
}
public async Task ProcessOrder(Order order)
{
// Magic strings everywhere - typos cause runtime errors
var maxRetries = _configuration.GetValue<int>("Processing:MaxRetries");
var timeout = _configuration.GetValue<int>("Processing:TimeoutSeconds");
var notifyAdmin = _configuration.GetValue<bool>("Processing:NotifyAdmin");
// No compile-time safety
// No validation
// Scattered throughout code
}
}
Problems:
- Magic strings can lead to typos
- No IntelliSense support
- Runtime errors if keys are wrong
- Difficult refactoring across the codebase
- No validation of configuration values
- Scattered configuration access makes maintenance hard
The Solution: Strongly-Typed Options
// ✅ Strongly-typed configuration with IOptions<T>
public class ProcessingOptions
{
public int MaxRetries { get; set; }
public int TimeoutSeconds { get; set; }
public bool NotifyAdmin { get; set; }
public string AdminEmail { get; set; } = string.Empty;
}
public class OrderService
{
private readonly ProcessingOptions _options;
public OrderService(IOptions<ProcessingOptions> options)
{
_options = options.Value;
}
public async Task ProcessOrder(Order order)
{
// IntelliSense, compile-time safety, clean code
var maxRetries = _options.MaxRetries;
var timeout = _options.TimeoutSeconds;
if (_options.NotifyAdmin)
{
await SendNotification(_options.AdminEmail);
}
}
}
Benefits:
- Type safety with compile-time checking
- IntelliSense support
- Centralized configuration model
- Easy validation with data annotations
- Better testability with plain POCOs
- Refactoring-friendly
Step-by-Step: Implementing IOptions
Let’s build a complete example from scratch.
Step 1: Define Configuration POCO
Create a class representing your configuration section:
public class EmailOptions
{
public const string SectionName = "Email";
public string SmtpServer { get; set; } = string.Empty;
public int SmtpPort { get; set; }
public string FromAddress { get; set; } = string.Empty;
public string FromName { get; set; } = string.Empty;
public bool UseSsl { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public int MaxRetries { get; set; } = 3;
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(5);
}
Step 2: Add Configuration to appsettings.json
{
"Email": {
"SmtpServer": "smtp.gmail.com",
"SmtpPort": 587,
"FromAddress": "noreply@example.com",
"FromName": "My Application",
"UseSsl": true,
"Username": "your-email@gmail.com",
"MaxRetries": 5,
"RetryDelay": "00:00:10"
}
}
Step 3: Register Options in DI Container
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Bind configuration section to POCO and register in DI
builder.Services.Configure<EmailOptions>(
builder.Configuration.GetSection(EmailOptions.SectionName)
);
var app = builder.Build();
Step 4: Inject and Use IOptions
public class EmailService
{
private readonly EmailOptions _emailOptions;
private readonly ILogger<EmailService> _logger;
public EmailService(
IOptions<EmailOptions> emailOptions,
ILogger<EmailService> logger)
{
_emailOptions = emailOptions.Value;
_logger = logger;
}
public async Task SendEmailAsync(string to, string subject, string body)
{
using var client = new SmtpClient(_emailOptions.SmtpServer, _emailOptions.SmtpPort)
{
EnableSsl = _emailOptions.UseSsl,
Credentials = string.IsNullOrEmpty(_emailOptions.Username)
? null
: new NetworkCredential(_emailOptions.Username, _emailOptions.Password)
};
var message = new MailMessage(
from: new MailAddress(_emailOptions.FromAddress, _emailOptions.FromName),
to: new MailAddress(to)
)
{
Subject = subject,
Body = body,
IsBodyHtml = true
};
for (int attempt = 1; attempt <= _emailOptions.MaxRetries; attempt++)
{
try
{
await client.SendMailAsync(message);
_logger.LogInformation("Email sent successfully to {To}", to);
return;
}
catch (Exception ex) when (attempt < _emailOptions.MaxRetries)
{
_logger.LogWarning(ex, "Email send attempt {Attempt} failed, retrying...", attempt);
await Task.Delay(_emailOptions.RetryDelay);
}
}
_logger.LogError("Failed to send email after {MaxRetries} attempts", _emailOptions.MaxRetries);
throw new InvalidOperationException("Could not send email after maximum retries");
}
}
Deep Dive: IOptions vs IOptionsSnapshot vs IOptionsMonitor
.NET provides three interfaces for accessing options, each with different lifetime and reloading behavior.
IOptions
Lifecycle: Registered as Singleton
Reloading: No - Values are cached forever
Thread Safety: Thread-safe
public class MyService
{
private readonly EmailOptions _options;
public MyService(IOptions<EmailOptions> options)
{
// .Value is computed once and cached
_options = options.Value;
}
}
When to Use:
- Configuration never changes at runtime
- Performance-critical scenarios (no reloading overhead)
- Singleton services
- Simple applications
Registration:
builder.Services.Configure<EmailOptions>(
builder.Configuration.GetSection("Email")
);
IOptionsSnapshot
Lifecycle: Registered as Scoped
Reloading: Yes - Values are recomputed per scope (per request in web apps)
Thread Safety: Thread-safe within a scope
public class MyController : ControllerBase
{
private readonly EmailOptions _options;
public MyController(IOptionsSnapshot<EmailOptions> options)
{
// .Value is computed once per HTTP request
// If config file changes mid-request, this instance won't see it
_options = options.Value;
}
}
When to Use:
- Configuration can be reloaded but should remain consistent within a request
- Scoped or Transient services
- Web applications where config changes between requests
- Named options (covered later)
Registration:
builder.Services.Configure<EmailOptions>(
builder.Configuration.GetSection("Email")
);
// IOptionsSnapshot is automatically available
IOptionsMonitor
Lifecycle: Registered as Singleton
Reloading: Yes - Values are recomputed on every access
Thread Safety: Thread-safe
Change Notifications: Supports OnChange callback
public class BackgroundEmailService : BackgroundService
{
private readonly IOptionsMonitor<EmailOptions> _optionsMonitor;
private readonly ILogger<BackgroundEmailService> _logger;
public BackgroundEmailService(
IOptionsMonitor<EmailOptions> optionsMonitor,
ILogger<BackgroundEmailService> logger)
{
_optionsMonitor = optionsMonitor;
_logger = logger;
// Subscribe to configuration changes
_optionsMonitor.OnChange(options =>
{
_logger.LogInformation(
"Email configuration changed: SmtpServer = {SmtpServer}",
options.SmtpServer
);
});
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Always gets the latest configuration
var currentOptions = _optionsMonitor.CurrentValue;
_logger.LogDebug("Using SMTP server: {SmtpServer}", currentOptions.SmtpServer);
await ProcessEmailQueue(currentOptions);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
When to Use:
- Configuration must reload without restarting the app
- Long-running Singleton services
- Background services or hosted services
- Need to react to configuration changes with
OnChange - Real-time configuration updates
Registration:
builder.Services.Configure<EmailOptions>(
builder.Configuration.GetSection("Email")
);
// IOptionsMonitor is automatically available
Comparison Table
| Feature | IOptions | IOptionsSnapshot | IOptionsMonitor |
|---|---|---|---|
| Lifetime | Singleton | Scoped | Singleton |
| Reloading | ❌ No | ✅ Per scope | ✅ Always |
| Change Notifications | ❌ No | ❌ No | ✅ Yes (OnChange) |
| Named Options | ❌ No | ✅ Yes | ✅ Yes |
| Performance | ⚡ Fastest | 🔄 Medium | 🔄 Medium |
| Use Case | Static config | Per-request reload | Real-time reload |
Choosing the Right Interface
// ✅ Use IOptions<T> when config never changes
public class DatabaseMigrator
{
public DatabaseMigrator(IOptions<DatabaseOptions> options) { }
}
// ✅ Use IOptionsSnapshot<T> in web request handlers
public class OrdersController : ControllerBase
{
public OrdersController(IOptionsSnapshot<OrderingOptions> options) { }
}
// ✅ Use IOptionsMonitor<T> in long-running background services
public class MetricsCollectorService : BackgroundService
{
public MetricsCollectorService(IOptionsMonitor<MetricsOptions> options) { }
}
Options Validation
Configuration errors should be caught early, ideally at startup, not when processing production traffic.
Data Annotations Validation
Use standard validation attributes:
using System.ComponentModel.DataAnnotations;
public class EmailOptions
{
public const string SectionName = "Email";
[Required(ErrorMessage = "SMTP server is required")]
[RegularExpression(@"^[\w\.-]+$", ErrorMessage = "Invalid SMTP server format")]
public string SmtpServer { get; set; } = string.Empty;
[Range(1, 65535, ErrorMessage = "Port must be between 1 and 65535")]
public int SmtpPort { get; set; }
[Required]
[EmailAddress(ErrorMessage = "Invalid email address format")]
public string FromAddress { get; set; } = string.Empty;
[Required]
[MinLength(1, ErrorMessage = "From name cannot be empty")]
public string FromName { get; set; } = string.Empty;
public bool UseSsl { get; set; }
[EmailAddress]
public string? Username { get; set; }
[Range(1, 10, ErrorMessage = "Max retries must be between 1 and 10")]
public int MaxRetries { get; set; } = 3;
}
Register with Validation:
builder.Services.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart(); // Fail fast at startup
Custom Validation
For complex business rules:
public class EmailOptions
{
public string SmtpServer { get; set; } = string.Empty;
public int SmtpPort { get; set; }
public bool UseSsl { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
}
// Custom validator
builder.Services.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
.Validate(options =>
{
// Username and password must be provided together
if (!string.IsNullOrEmpty(options.Username) && string.IsNullOrEmpty(options.Password))
return false;
if (!string.IsNullOrEmpty(options.Password) && string.IsNullOrEmpty(options.Username))
return false;
// SSL is required for Gmail
if (options.SmtpServer.Contains("gmail", StringComparison.OrdinalIgnoreCase)
&& !options.UseSsl)
return false;
return true;
}, "Invalid email configuration: Username/Password must be provided together, and Gmail requires SSL")
.ValidateOnStart();
IValidateOptions Interface
For more complex validation logic:
public class EmailOptionsValidator : IValidateOptions<EmailOptions>
{
public ValidateOptionsResult Validate(string? name, EmailOptions options)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(options.SmtpServer))
errors.Add("SMTP server is required");
if (options.SmtpPort < 1 || options.SmtpPort > 65535)
errors.Add("SMTP port must be between 1 and 65535");
if (!IsValidEmail(options.FromAddress))
errors.Add($"From address '{options.FromAddress}' is not a valid email");
// Complex business rule
if (options.UseSsl && options.SmtpPort == 25)
errors.Add("SSL cannot be used with port 25 (use 587 or 465 instead)");
if (!string.IsNullOrEmpty(options.Username) && string.IsNullOrEmpty(options.Password))
errors.Add("Password is required when Username is specified");
if (errors.Any())
return ValidateOptionsResult.Fail(errors);
return ValidateOptionsResult.Success;
}
private bool IsValidEmail(string email)
{
try
{
var addr = new System.Net.Mail.MailAddress(email);
return addr.Address == email;
}
catch
{
return false;
}
}
}
// Register validator
builder.Services.AddSingleton<IValidateOptions<EmailOptions>, EmailOptionsValidator>();
builder.Services.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
.ValidateOnStart();
ValidateOnStart
Critical for production: Validates configuration when the application starts, not when first accessed.
builder.Services.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart(); // Application won't start if config is invalid
// Or validate all options at once
builder.Services.AddOptionsWithValidateOnStart<EmailOptions>(
builder.Configuration.GetSection(EmailOptions.SectionName)
);
Without ValidateOnStart:
- Invalid configuration detected when first accessed (could be hours/days later)
- Partial application startup
- Production incidents
With ValidateOnStart:
- Invalid configuration detected immediately at startup
- Application fails to start
- Catch errors in CI/CD pipeline
Advanced Topics
Named Options
Use multiple configurations of the same type:
public class DatabaseOptions
{
public string ConnectionString { get; set; } = string.Empty;
public int MaxRetries { get; set; }
public TimeSpan CommandTimeout { get; set; }
}
// appsettings.json
{
"Databases": {
"Primary": {
"ConnectionString": "Server=primary;Database=Main",
"MaxRetries": 3,
"CommandTimeout": "00:00:30"
},
"ReadReplica": {
"ConnectionString": "Server=replica;Database=Main",
"MaxRetries": 5,
"CommandTimeout": "00:01:00"
}
}
}
// Register named options
builder.Services.Configure<DatabaseOptions>("Primary",
builder.Configuration.GetSection("Databases:Primary"));
builder.Services.Configure<DatabaseOptions>("ReadReplica",
builder.Configuration.GetSection("Databases:ReadReplica"));
// Usage with IOptionsSnapshot or IOptionsMonitor
public class OrderRepository
{
private readonly DatabaseOptions _primaryDb;
private readonly DatabaseOptions _replicaDb;
public OrderRepository(IOptionsSnapshot<DatabaseOptions> databaseOptions)
{
// Get named option
_primaryDb = databaseOptions.Get("Primary");
_replicaDb = databaseOptions.Get("ReadReplica");
}
public async Task<Order> GetOrderAsync(int id)
{
// Read from replica
using var connection = new SqlConnection(_replicaDb.ConnectionString);
// Query...
}
public async Task SaveOrderAsync(Order order)
{
// Write to primary
using var connection = new SqlConnection(_primaryDb.ConnectionString);
// Save...
}
}
Factory Pattern with Named Options:
public interface IDatabaseConnectionFactory
{
SqlConnection CreatePrimaryConnection();
SqlConnection CreateReplicaConnection();
}
public class DatabaseConnectionFactory : IDatabaseConnectionFactory
{
private readonly IOptionsSnapshot<DatabaseOptions> _options;
public DatabaseConnectionFactory(IOptionsSnapshot<DatabaseOptions> options)
{
_options = options;
}
public SqlConnection CreatePrimaryConnection()
=> new SqlConnection(_options.Get("Primary").ConnectionString);
public SqlConnection CreateReplicaConnection()
=> new SqlConnection(_options.Get("ReadReplica").ConnectionString);
}
Post-Configuration
Modify options after binding but before validation:
public class EmailOptions
{
public string SmtpServer { get; set; } = string.Empty;
public string FromAddress { get; set; } = string.Empty;
public string? ApiKey { get; set; }
}
// Register post-configuration
builder.Services.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection("Email"))
.PostConfigure(options =>
{
// Override from environment variable if available
var envApiKey = Environment.GetEnvironmentVariable("EMAIL_API_KEY");
if (!string.IsNullOrEmpty(envApiKey))
{
options.ApiKey = envApiKey;
}
// Ensure trailing dot for FQDN
if (!options.SmtpServer.EndsWith('.') && options.SmtpServer.Contains('.'))
{
options.SmtpServer += ".";
}
// Lowercase email addresses
options.FromAddress = options.FromAddress.ToLowerInvariant();
})
.ValidateDataAnnotations()
.ValidateOnStart();
Use Cases for Post-Configuration:
- Override with environment variables or secrets
- Apply default values
- Transform or normalize values
- Encrypt/decrypt sensitive data
Reloadable Configuration
Configuration files can be reloaded without restarting:
// Enable reloadOnChange
"reloadOnChange": true
builder.Configuration.AddJsonFile(
"appsettings.json",
optional: false,
reloadOnChange: true // Enable hot reload
);
React to Changes:
public class ConfigurationMonitorService : BackgroundService
{
private readonly IOptionsMonitor<EmailOptions> _optionsMonitor;
private readonly ILogger<ConfigurationMonitorService> _logger;
private IDisposable? _changeListener;
public ConfigurationMonitorService(
IOptionsMonitor<EmailOptions> optionsMonitor,
ILogger<ConfigurationMonitorService> logger)
{
_optionsMonitor = optionsMonitor;
_logger = logger;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
// Register change callback
_changeListener = _optionsMonitor.OnChange((options, name) =>
{
_logger.LogInformation(
"Email configuration changed. New SMTP: {SmtpServer}:{SmtpPort}",
options.SmtpServer,
options.SmtpPort
);
// Reinitialize resources if needed
ReinitializeEmailClient(options);
});
return Task.CompletedTask;
}
private void ReinitializeEmailClient(EmailOptions options)
{
// Recreate connection pools, caches, etc.
}
public override void Dispose()
{
_changeListener?.Dispose();
base.Dispose();
}
}
Important: Reloadable configuration works with:
- ✅
IOptionsSnapshot<T>(per-scope reload) - ✅
IOptionsMonitor<T>(real-time reload) - ❌
IOptions<T>(cached forever, no reload)
Real-World Production Tips
1. Separate Configuration from Secrets
Never store secrets in appsettings.json:
// ❌ BAD: Secrets in source control
{
"Database": {
"ConnectionString": "Server=prod;User=admin;Password=secret123"
}
}
Better Approaches:
Development: User Secrets
dotnet user-secrets init
dotnet user-secrets set "Database:ConnectionString" "Server=localhost;..."
2. Validate Configuration in Integration Tests
public class ConfigurationTests
{
[Fact]
public void EmailOptions_ShouldBeValid_InDevelopmentEnvironment()
{
// Arrange
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.Development.json")
.Build();
var services = new ServiceCollection();
services.AddOptions<EmailOptions>()
.Bind(configuration.GetSection("Email"))
.ValidateDataAnnotations();
var serviceProvider = services.BuildServiceProvider();
// Act & Assert
var options = serviceProvider.GetRequiredService<IOptions<EmailOptions>>();
var emailOptions = options.Value; // Should not throw
Assert.NotEmpty(emailOptions.SmtpServer);
Assert.True(emailOptions.SmtpPort > 0);
}
[Fact]
public void InvalidEmailOptions_ShouldThrow_WithValidateOnStart()
{
// Arrange
var invalidConfig = new Dictionary<string, string>
{
["Email:SmtpServer"] = "", // Invalid: required
["Email:SmtpPort"] = "99999" // Invalid: out of range
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(invalidConfig)
.Build();
var services = new ServiceCollection();
services.AddOptions<EmailOptions>()
.Bind(configuration.GetSection("Email"))
.ValidateDataAnnotations()
.ValidateOnStart();
// Act & Assert
var exception = Assert.Throws<OptionsValidationException>(
() => services.BuildServiceProvider(validateScopes: true)
);
Assert.Contains("SMTP server", exception.Message);
}
}
3. Use Extension Methods for Clean Registration
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddEmailServices(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOptions<EmailOptions>()
.Bind(configuration.GetSection(EmailOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<IValidateOptions<EmailOptions>, EmailOptionsValidator>();
services.AddTransient<IEmailService, EmailService>();
return services;
}
}
// Clean program.cs
builder.Services.AddEmailServices(builder.Configuration);
4. Monitor Configuration in Production
public class ConfigurationHealthCheck : IHealthCheck
{
private readonly IOptionsMonitor<EmailOptions> _emailOptions;
private readonly IOptionsMonitor<DatabaseOptions> _databaseOptions;
public ConfigurationHealthCheck(
IOptionsMonitor<EmailOptions> emailOptions,
IOptionsMonitor<DatabaseOptions> databaseOptions)
{
_emailOptions = emailOptions;
_databaseOptions = databaseOptions;
}
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
// Validate current configuration
var email = _emailOptions.CurrentValue;
var database = _databaseOptions.CurrentValue;
if (string.IsNullOrEmpty(email.SmtpServer))
return Task.FromResult(HealthCheckResult.Unhealthy("Email configuration invalid"));
if (string.IsNullOrEmpty(database.ConnectionString))
return Task.FromResult(HealthCheckResult.Unhealthy("Database configuration invalid"));
var data = new Dictionary<string, object>
{
["EmailSmtpServer"] = email.SmtpServer,
["DatabaseConfigured"] = true
};
return Task.FromResult(HealthCheckResult.Healthy("Configuration is valid", data));
}
catch (Exception ex)
{
return Task.FromResult(
HealthCheckResult.Unhealthy("Configuration error", ex)
);
}
}
}
// Register health check
builder.Services.AddHealthChecks()
.AddCheck<ConfigurationHealthCheck>("configuration");
5. Handle Missing Configuration Gracefully
public class EmailOptions
{
public bool Enabled { get; set; } = true; // Feature flag
public string SmtpServer { get; set; } = "localhost"; // Sensible defaults
public int SmtpPort { get; set; } = 25;
public string FromAddress { get; set; } = "noreply@localhost";
}
public class EmailService : IEmailService
{
private readonly EmailOptions _options;
private readonly ILogger<EmailService> _logger;
public EmailService(IOptions<EmailOptions> options, ILogger<EmailService> logger)
{
_options = options.Value;
_logger = logger;
}
public async Task SendEmailAsync(string to, string subject, string body)
{
if (!_options.Enabled)
{
_logger.LogInformation(
"Email sending is disabled. Would have sent to {To}: {Subject}",
to, subject
);
return;
}
// Send email...
}
}
Common Pitfalls
Pitfall 1: Using IOptions in Singletons for Reloadable Config
// ❌ WRONG: IOptions<T> in singleton with reloadable config
public class EmailBackgroundService : BackgroundService
{
private readonly EmailOptions _options;
public EmailBackgroundService(IOptions<EmailOptions> options) // Won't see reloads!
{
_options = options.Value;
}
}
// ✅ CORRECT: Use IOptionsMonitor<T>
public class EmailBackgroundService : BackgroundService
{
private readonly IOptionsMonitor<EmailOptions> _optionsMonitor;
public EmailBackgroundService(IOptionsMonitor<EmailOptions> optionsMonitor)
{
_optionsMonitor = optionsMonitor;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var currentOptions = _optionsMonitor.CurrentValue; // Always fresh
// Use currentOptions...
}
}
}
Pitfall 2: Not Using ValidateOnStart
// ❌ WRONG: No validation
builder.Services.Configure<EmailOptions>(
builder.Configuration.GetSection("Email")
);
// App starts successfully even with invalid config
// Fails later when EmailService is first used
// ✅ CORRECT: Fail fast
builder.Services.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection("Email"))
.ValidateDataAnnotations()
.ValidateOnStart(); // App won't start with invalid config
Pitfall 3: Mutable Options
// ❌ WRONG: Modifying options
public class MyService
{
public MyService(IOptions<EmailOptions> options)
{
var opts = options.Value;
opts.SmtpServer = "modified"; // Other services see this change!
}
}
// ✅ CORRECT: Treat options as immutable
public record EmailOptions
{
public string SmtpServer { get; init; } = string.Empty; // init-only
public int SmtpPort { get; init; }
}
Pitfall 4: Over-nesting Configuration
// ❌ WRONG: Deep nesting
{
"Application": {
"Services": {
"Email": {
"Configuration": {
"Smtp": {
"Server": {
"Address": "smtp.gmail.com"
}
}
}
}
}
}
}
// ✅ CORRECT: Flat, logical structure
{
"Email": {
"SmtpServer": "smtp.gmail.com",
"SmtpPort": 587
}
}
Conclusion
The .NET configuration system with IOptions<T> provides a robust, type-safe approach to managing application settings. Key takeaways:
- Use strongly-typed options instead of
IConfigurationdirectly - Choose the right interface:
IOptions<T>for static configurationIOptionsSnapshot<T>for per-request reloadIOptionsMonitor<T>for real-time reload and change notifications
- Always validate configuration with
ValidateDataAnnotations()andValidateOnStart() - Separate secrets from configuration files
- Test configuration in integration tests
- Monitor configuration health in production
By following these patterns, you’ll build maintainable, production-ready .NET applications with configuration that’s type-safe, validated, and easy to manage across environments.