Configuration
.NET configuration system, options pattern, and secrets management
You are an expert in the .NET configuration system for managing application settings, secrets, and strongly-typed options.
## Key Points
- **Always use the Options pattern** instead of reading `IConfiguration` directly in services. It provides compile-time safety, validation, and testability.
- **Call `ValidateOnStart()`** for all options registrations so misconfiguration is caught at application boot, not at first request.
- **Never store secrets in `appsettings.json`** — use User Secrets for development, environment variables or a vault service for production.
- **Use `IOptionsMonitor<T>`** in singletons and background services to react to configuration changes without restart.
- **Prefix environment variables** with your app name (`MYAPP_`) to avoid collisions with system or framework variables.
- **Keep options classes focused** — one class per configuration section. Do not create a god-options class with everything.
- **Injecting `IOptionsSnapshot<T>` into a singleton** — `IOptionsSnapshot` is scoped, and injecting it into a singleton captures a stale value. Use `IOptionsMonitor<T>` in singletons instead.
- **Forgetting the `__` separator for environment variables** — nested keys use double underscore (`Smtp__Host`), not colon (`Smtp:Host`), in environment variables.
- **Not setting `reloadOnChange: true`** — without it, changes to JSON files at runtime are not picked up.
- **Committing `appsettings.Development.json` with secrets** — even development secrets should use User Secrets (`dotnet user-secrets`), not checked-in JSON files.
- **Ignoring configuration binding errors silently** — by default, binding to a missing section produces default values without warning. Use validation to catch missing required fields.
- **Using `IConfiguration` in domain or service layers** — this couples your domain to the configuration infrastructure. Pass options or individual values through constructors instead.
## Quick Example
```csharp
// Not recommended for most cases, but useful for one-off reads
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
var smtpHost = builder.Configuration["Smtp:Host"];
var maxUpload = builder.Configuration.GetValue<int>("Features:MaxUploadSizeMb");
```skilldb get csharp-dotnet-skills/ConfigurationFull skill: 310 linesConfiguration and Options Pattern — C#/.NET
You are an expert in the .NET configuration system for managing application settings, secrets, and strongly-typed options.
Core Philosophy
Overview
.NET's configuration system provides a layered, provider-based approach to application settings. Configuration sources (JSON files, environment variables, Azure Key Vault, etc.) are merged in order, with later sources overriding earlier ones. The Options pattern binds configuration sections to strongly-typed C# classes, with support for validation, reloading, and named options.
Core Concepts
Configuration Sources and Ordering
var builder = WebApplication.CreateBuilder(args);
// Default ordering (already configured by CreateBuilder):
// 1. appsettings.json
// 2. appsettings.{Environment}.json
// 3. User secrets (Development only)
// 4. Environment variables
// 5. Command-line arguments
// Add custom sources
builder.Configuration
.AddJsonFile("features.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "MYAPP_");
appsettings.json Structure
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=MyApp;Trusted_Connection=true;"
},
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"Username": "noreply@example.com",
"Password": ""
},
"Features": {
"EnableNewDashboard": false,
"MaxUploadSizeMb": 25
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
Reading Configuration Directly
// Not recommended for most cases, but useful for one-off reads
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
var smtpHost = builder.Configuration["Smtp:Host"];
var maxUpload = builder.Configuration.GetValue<int>("Features:MaxUploadSizeMb");
Implementation Patterns
Options Pattern (Strongly-Typed Configuration)
public class SmtpOptions
{
public const string SectionName = "Smtp";
[Required]
public string Host { get; set; } = "";
[Range(1, 65535)]
public int Port { get; set; } = 587;
[Required]
public string Username { get; set; } = "";
[Required]
public string Password { get; set; } = "";
public bool UseSsl { get; set; } = true;
}
// Registration with validation
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart(); // Fails fast at startup if config is invalid
IOptions vs IOptionsSnapshot vs IOptionsMonitor
// IOptions<T> — singleton, read once at startup
public class StartupService(IOptions<SmtpOptions> options)
{
private readonly SmtpOptions _smtp = options.Value;
}
// IOptionsSnapshot<T> — scoped, re-reads per request (when reloadOnChange is true)
public class EmailService(IOptionsSnapshot<SmtpOptions> options)
{
public void Send()
{
var smtp = options.Value; // Fresh value per scope
}
}
// IOptionsMonitor<T> — singleton, provides change notifications
public class SmtpMonitor(IOptionsMonitor<SmtpOptions> monitor)
{
public SmtpMonitor()
{
monitor.OnChange(newOptions =>
{
Console.WriteLine($"SMTP config changed: {newOptions.Host}:{newOptions.Port}");
});
}
}
Custom Validation with IValidateOptions
public class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
public ValidateOptionsResult Validate(string? name, SmtpOptions options)
{
var failures = new List<string>();
if (string.IsNullOrWhiteSpace(options.Host))
failures.Add("SMTP host is required");
if (options.Port is < 1 or > 65535)
failures.Add($"Port {options.Port} is out of valid range");
if (options.UseSsl && options.Port == 25)
failures.Add("Port 25 does not support SSL. Use 587 or 465.");
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}
// Registration
builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();
Named Options
// appsettings.json
// {
// "Storage": {
// "Images": { "BasePath": "/data/images", "MaxSizeMb": 10 },
// "Documents": { "BasePath": "/data/docs", "MaxSizeMb": 50 }
// }
// }
public class StorageOptions
{
public string BasePath { get; set; } = "";
public int MaxSizeMb { get; set; }
}
builder.Services.Configure<StorageOptions>("Images",
builder.Configuration.GetSection("Storage:Images"));
builder.Services.Configure<StorageOptions>("Documents",
builder.Configuration.GetSection("Storage:Documents"));
// Resolve by name
public class UploadService(IOptionsSnapshot<StorageOptions> options)
{
public void UploadImage(Stream file)
{
var config = options.Get("Images");
// config.BasePath == "/data/images"
}
public void UploadDocument(Stream file)
{
var config = options.Get("Documents");
// config.BasePath == "/data/docs"
}
}
Secrets Management
// Development: User Secrets (stored outside the project directory)
// dotnet user-secrets init
// dotnet user-secrets set "Smtp:Password" "s3cret"
// Production: Environment variables (flat key format)
// MYAPP_Smtp__Password=s3cret
// (double underscore __ replaces the : section separator)
// Production: Azure Key Vault
builder.Configuration.AddAzureKeyVault(
new Uri("https://myapp-vault.vault.azure.net/"),
new DefaultAzureCredential());
// Production: AWS Secrets Manager, HashiCorp Vault, etc.
// Use the appropriate NuGet configuration provider
Environment-Specific Configuration
// appsettings.Development.json overrides appsettings.json in Development
// appsettings.Production.json overrides in Production
// Check environment in code
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI();
}
if (app.Environment.IsProduction())
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
Configuration for Background Services
public class PollingOptions
{
public const string SectionName = "Polling";
public int IntervalSeconds { get; set; } = 30;
public int MaxRetries { get; set; } = 3;
}
public class PollingService(
IOptionsMonitor<PollingOptions> optionsMonitor,
ILogger<PollingService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var options = optionsMonitor.CurrentValue; // Always fresh
logger.LogDebug("Polling with interval {Interval}s", options.IntervalSeconds);
try
{
await DoPollAsync(ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Polling failed");
}
await Task.Delay(TimeSpan.FromSeconds(options.IntervalSeconds), ct);
}
}
private Task DoPollAsync(CancellationToken ct) => Task.CompletedTask;
}
Best Practices
- Always use the Options pattern instead of reading
IConfigurationdirectly in services. It provides compile-time safety, validation, and testability. - Call
ValidateOnStart()for all options registrations so misconfiguration is caught at application boot, not at first request. - Never store secrets in
appsettings.json— use User Secrets for development, environment variables or a vault service for production. - Use
IOptionsMonitor<T>in singletons and background services to react to configuration changes without restart. - Prefix environment variables with your app name (
MYAPP_) to avoid collisions with system or framework variables. - Keep options classes focused — one class per configuration section. Do not create a god-options class with everything.
Common Pitfalls
- Injecting
IOptionsSnapshot<T>into a singleton —IOptionsSnapshotis scoped, and injecting it into a singleton captures a stale value. UseIOptionsMonitor<T>in singletons instead. - Forgetting the
__separator for environment variables — nested keys use double underscore (Smtp__Host), not colon (Smtp:Host), in environment variables. - Not setting
reloadOnChange: true— without it, changes to JSON files at runtime are not picked up. - Committing
appsettings.Development.jsonwith secrets — even development secrets should use User Secrets (dotnet user-secrets), not checked-in JSON files. - Ignoring configuration binding errors silently — by default, binding to a missing section produces default values without warning. Use validation to catch missing required fields.
- Using
IConfigurationin domain or service layers — this couples your domain to the configuration infrastructure. Pass options or individual values through constructors instead.
Anti-Patterns
Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.
Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.
Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.
Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.
Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.
Install this skill directly: skilldb add csharp-dotnet-skills
Related Skills
Aspnet Minimal API
ASP.NET Minimal APIs for building lightweight HTTP endpoints in .NET
Async Patterns
Async/await patterns, Task-based concurrency, and cancellation in C#/.NET
Blazor
Blazor component model, rendering modes, and interactive web UI development in .NET
Dependency Injection
Dependency injection patterns and service registration in .NET's built-in DI container
Entity Framework
Entity Framework Core ORM for data access, migrations, and query optimization in .NET
Mediatr
MediatR library for implementing CQRS, commands, queries, and pipeline behaviors in .NET