C# Configuration & Options Pattern
C# Configuration & Options Pattern
Configuration is core to building maintainable ASP.NET Core apps. This post walks through the built-in layered configuration model, the Options pattern for strongly-typed settings, the differences between IOptions<T>, IOptionsSnapshot<T> and IOptionsMonitor<T>, named options, validation, and common anti-patterns.
Configuration in ASP.NET Core
- ASP.NET Core uses a layered configuration system: multiple sources are combined and later sources override earlier ones.
- Default configuration sources (last wins):
1. appsettings.json
2. appsettings.{Environment}.json (e.g., appsettings.Development.json)
3. User Secrets (Development only)
4. Environment Variables
5. Command-line arguments
appsettings.jsonis the primary, file-based config store:
{
"ConnectionStrings": {
"Default": "Server=localhost;Database=MyApp;Trusted_Connection=true"
},
"Logging": { "LogLevel": { "Default": "Information" } },
"EmailSettings": {
"SmtpHost": "smtp.example.com",
"SmtpPort": 587,
"FromAddress": "noreply@example.com",
"EnableSsl": true
},
"FeatureFlags": { "EnableNewCheckout": false, "MaxRetries": 3 }
}
- You can access configuration directly via
IConfigurationbut that is error-prone (magic strings, no type-safety). Prefer the Options pattern.
// IConfiguration - raw access (not recommended for most scenarios)
var smtpHost = builder.Configuration["EmailSettings:SmtpHost"];
var port = builder.Configuration.GetValue<int>("EmailSettings:SmtpPort");
var connStr = builder.Configuration.GetConnectionString("Default");
Sample appsetting
Assume the following setup: appsettings.json is used for all environments, and appsettings.dev.json is used when the ASPNETCORE_ENVIRONMENT is set to dev.
appsettings.json
"MyApp": {
"ApiKey": "my-key-12345"
}
appsettings.dev.json
"MyApp": {
"ApiKey": "my-key-10000"
}
appsettings.prd.json
// No ApiKey for MyApp in this file
Now, if you run dotnet run, you'll see different values depending on the environment:
- If you run with
ASPNETCORE_ENVIRONMENT=dev, you'll getmy-key-10000for ApiKey. - If you run with
ASPNETCORE_ENVIRONMENT=prd, the config will returnmy-key-12345.
Use user-secrets
You can use user-secrets to override config values from appsettings. User secrets allow you to store sensitive values on your local development machine, so you don't need to check credentials into the repo. For development or production, you can inject these values into the application from a Vault or environment variables.
dotnet user-secrets init
dotnet user-secrets set "MyApp:ApiKey" "99999"
dotnet user-secrets list
A few other commands for user-secrets
# Remove a secret
dotnet user-secrets remove "MyApp:ApiKey"
# Clear all secrets
dotnet user-secrets clear
Use Environment Variables
Running the following PowerShell command will override both appsettings and user-secrets:
# Set and run in one command (PowerShell)
$env:MyApp__ApiKey="secret"; dotnet run
Use command-line arguments
Command-line arguments will override all other configuration sources:
dotnet run --MyApp:ApiKey="command-line-key-12345"
The Options Pattern (IOptions<T>)
- The Options pattern binds configuration sections to strongly-typed classes. This gives type-safety, validation, and easier testing.
public class EmailSettings
{
public const string SectionName = "EmailSettings";
public string SmtpHost { get; init; } = string.Empty;
public int SmtpPort { get; init; } = 587;
public string FromAddress { get; init; } = string.Empty;
public bool EnableSsl { get; init; } = true;
}
// Register in Program.cs
builder.Services.Configure<EmailSettings>(
builder.Configuration.GetSection(EmailSettings.SectionName));
// Consume via DI
public class EmailSender
{
private readonly EmailSettings _settings;
public EmailSender(IOptions<EmailSettings> options) => _settings = options.Value;
public async Task SendAsync(string to, string body, CancellationToken ct)
{
using var client = new SmtpClient(_settings.SmtpHost, _settings.SmtpPort);
client.EnableSsl = _settings.EnableSsl;
await client.SendMailAsync(new MailMessage(_settings.FromAddress, to, "Subject", body), ct);
}
}
IOptions<T> vs IOptionsSnapshot<T> vs IOptionsMonitor<T>
| Interface | Lifetime | Reloads on Change | Use Case |
|---|---|---|---|
IOptions<T> |
Singleton | No | Settings that never change at runtime |
IOptionsSnapshot<T> |
Scoped | Yes (per-request) | Settings that may change and are consumed in scoped services |
IOptionsMonitor<T> |
Singleton | Yes (live) | Singleton services that need live reload and change callbacks |
-
IOptions<T>: read once at startup — simplest and fastest. Use for secrets or values that won't change during runtime. -
IOptionsSnapshot<T>: refreshed per request/scope. Useful for feature flags or settings you may update and expect to take effect on the next request. Cannot be injected into Singleton services. -
IOptionsMonitor<T>: supports live reload andOnChangecallbacks. Use when a Singleton needs to react to config changes.
// Example: IOptionsMonitor with change callback
public class CacheService : IDisposable
{
private CacheSettings _settings;
private readonly IDisposable? _changeListener;
public CacheService(IOptionsMonitor<CacheSettings> monitor)
{
_settings = monitor.CurrentValue;
_changeListener = monitor.OnChange(newSettings =>
{
_settings = newSettings;
Console.WriteLine($"Cache updated: TTL={newSettings.TtlMinutes}m");
});
}
public void Dispose() => _changeListener?.Dispose();
}
Named Options — multiple configurations of the same type
When you need multiple instances of the same settings class with different values (e.g., two blob containers), use named options:
{
"Storage": {
"Images": { "ConnectionString": "...", "ContainerName": "images" },
"Documents": { "ConnectionString": "...", "ContainerName": "documents" }
}
}
public class BlobStorageSettings { public string ConnectionString { get; init; } = string.Empty; public string ContainerName { get; init; } = string.Empty; }
builder.Services.Configure<BlobStorageSettings>("Images", builder.Configuration.GetSection("Storage:Images"));
builder.Services.Configure<BlobStorageSettings>("Documents", builder.Configuration.GetSection("Storage:Documents"));
public class StorageService
{
private readonly IOptionsSnapshot<BlobStorageSettings> _options;
public StorageService(IOptionsSnapshot<BlobStorageSettings> options) => _options = options;
public string GetImageContainer() => _options.Get("Images").ContainerName;
public string GetDocumentContainer() => _options.Get("Documents").ContainerName;
}
Options Validation
Always validate options to catch misconfiguration early. You can use data annotations, inline validation, or implement IValidateOptions<T>.
public class EmailSettings
{
[Required]
public string SmtpHost { get; init; } = string.Empty;
[Range(1, 65535)]
public int SmtpPort { get; init; } = 587;
[Required, EmailAddress]
public string FromAddress { get; init; } = string.Empty;
}
builder.Services
.AddOptions<EmailSettings>()
.BindConfiguration(EmailSettings.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
Inline validation example:
builder.Services
.AddOptions<EmailSettings>()
.BindConfiguration("EmailSettings")
.Validate(settings => !string.IsNullOrWhiteSpace(settings.SmtpHost) && settings.SmtpPort is >=1 and <=65535, "EmailSettings validation failed")
.ValidateOnStart();
Or implement reusable validation via IValidateOptions<T> for complex checks.
Note: .ValidateOnStart() is recommended so the app fails fast on invalid config, that way can detect the error earlier.
For example: if you app configured kafka credential, you asked devOps to setup the value but by some reason the value if misconfigured, would you still want to app to running and fail the API calls? or not let it start-up after the deployment?
PostConfigure
Use PostConfigure<T> to compute defaults or derived values after binding, use this when want fallback logic for a config.
builder.Services.PostConfigure<EmailSettings>(settings =>
{
if (string.IsNullOrEmpty(settings.FromAddress))
settings.FromAddress = $"noreply@{settings.SmtpHost}";
});
Configuration Sources Quick Tips
- User Secrets: development-only secure storage for secrets outside source control (
dotnet user-secrets). - Environment variables: preferred in production (use
__double-underscore to separate sections). - Command-line args: useful for short-lived overrides during startup.
Common Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
IConfiguration everywhere |
Magic strings, no type safety | Use IOptions<T> |
Secrets in appsettings.json |
Committed to source control | Use User Secrets (dev) or env vars (prod) |
| No validation on options | Bad config causes runtime errors | ValidateDataAnnotations() + ValidateOnStart() |
IOptions<T> in Singleton that needs reloads |
Values never update | Use IOptionsMonitor<T> |
IOptionsSnapshot<T> in Singleton |
Runtime error (Scoped in Singleton) | Use IOptionsMonitor<T> |
Binding Patterns
Configure<T>: common, simple registration.AddOptions<T>().BindConfiguration(...)— supports validation andValidateOnStart().Get<T>()onIConfigurationSection— immediate binding (no DI).
Summary — Configuration Checklist
| Topic | Key Takeaway |
|---|---|
| Configuration sources | appsettings.json → env JSON → User Secrets → env vars → CLI |
| Options pattern | Bind config to strong types for safety and testability |
IOptions<T> |
Singleton, read once — for static settings |
IOptionsSnapshot<T> |
Scoped, refreshed per request — for changing settings in scoped services |
IOptionsMonitor<T> |
Singleton, live reload + OnChange — for singletons that react to updates |
| Named options | Use when you need multiple configurations of same type |
| Validation | Use data annotations or IValidateOptions<T> and ValidateOnStart() |
| Secrets | Don't store in source control — use User Secrets (dev) or env vars (prod) |