Configuration & Options
Key Points
IConfigurationis a hierarchical key/value store assembled from multiple providers: appsettings.json, environment variables, command-line, secrets, Azure App Config, etc.- Provider order matters — later providers override earlier. Default chain:
appsettings.json→appsettings.{Env}.json→ user secrets (Dev) → environment variables → command-line. - Bind to options classes with
Configure<T>/BindConfiguration("Section")rather than reading flat keys. IOptions<T>vsIOptionsSnapshot<T>vsIOptionsMonitor<T>— singleton snapshot vs per-scope reload vs change-notification.Validate/ValidateOnStartcatches misconfiguration at startup, not at first use.- Keep secrets out of
appsettings.json— use User Secrets in Dev, Key Vault / managed identity in Prod.
Concepts (deep dive)
Provider chain (default)
ChainedConfigurationProvider (host config)
│
▼
appsettings.json
│
▼
appsettings.{ENV}.json
│
▼
User Secrets (Development only)
│
▼
Environment variables
│
▼
Command-line args
Later in the chain → higher priority. So ENVIRONMENT_VARIABLE overrides appsettings.json.
Reading configuration
// Flat (avoid except quick scripts):
string connStr = builder.Configuration["ConnectionStrings:Db"];
// Bind to options (preferred):
public class DbOptions
{
public string ConnectionString { get; init; } = "";
public int CommandTimeoutSeconds { get; init; } = 30;
}
builder.Services.Configure<DbOptions>(builder.Configuration.GetSection("Db"));
appsettings.json:
IOptions<T> family
| Interface | Lifetime | Reload behavior |
|---|---|---|
IOptions<T> | Singleton | Value computed once at startup; never reloads |
IOptionsSnapshot<T> | Scoped | Recomputed per request |
IOptionsMonitor<T> | Singleton | CurrentValue reflects latest; supports OnChange callback |
public class Service(IOptions<DbOptions> opts) { } // simplest; no reload
public class Service(IOptionsSnapshot<DbOptions> opts) { } // scoped; per-request
public class Service(IOptionsMonitor<DbOptions> opts) // singleton; live updates
{
public Service(IOptionsMonitor<DbOptions> mon)
{
mon.OnChange(o => Console.WriteLine($"reloaded: {o.ConnectionString}"));
}
}
💡 Senior tip: in singletons, prefer
IOptionsMonitor<T>. In scoped/transient services,IOptionsSnapshot<T>is fine.
Validation
builder.Services.AddOptions<DbOptions>()
.Bind(builder.Configuration.GetSection("Db"))
.ValidateDataAnnotations()
.Validate(o => o.CommandTimeoutSeconds > 0, "CommandTimeoutSeconds must be > 0")
.ValidateOnStart();
ValidateOnStart runs validation at host startup — invalid config crashes early, not at first request. Always use it for important options.
ValidateDataAnnotations honors [Required], [Range], etc., on the options class:
public class DbOptions
{
[Required, MinLength(1)] public string ConnectionString { get; init; } = "";
[Range(1, 600)] public int CommandTimeoutSeconds { get; init; } = 30;
}
Secrets
# Set a secret in development
dotnet user-secrets init
dotnet user-secrets set "Db:ConnectionString" "Server=localhost;..."
User Secrets are stored in %APPDATA%/Microsoft/UserSecrets/<id>/secrets.json — outside your repo. Loaded automatically when ASPNETCORE_ENVIRONMENT=Development.
In production: environment variables for simple cases, Azure Key Vault (with managed identity) for serious cases:
if (!builder.Environment.IsDevelopment())
{
var kvUri = new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/");
builder.Configuration.AddAzureKeyVault(kvUri, new DefaultAzureCredential());
}
DefaultAzureCredential tries managed identity → Azure CLI → Visual Studio creds in order. No secrets in code or files.
Environment variables — naming
__ is the section separator (: doesn't work cross-shell). Prefix ASPNETCORE_ is automatically stripped:
# These set Db:ConnectionString:
export Db__ConnectionString="..."
export ASPNETCORE_Db__ConnectionString="..."
builder.Configuration.AddEnvironmentVariables(prefix: "MYAPP_");
// MYAPP_Db__ConnectionString → Db:ConnectionString
Reload on file change
reloadOnChange: true watches the file and reloads if it changes. Combined with IOptionsMonitor<T>, your services see updates without a restart. Use sparingly — file watching has cost; prefer Azure App Configuration / push-based reload for production.
Azure App Configuration
builder.Configuration.AddAzureAppConfiguration(o =>
{
o.Connect(new Uri(endpoint), new DefaultAzureCredential())
.Select("MyApp:*")
.ConfigureRefresh(r => r.Register("MyApp:Sentinel", refreshAll: true)
.SetCacheExpiration(TimeSpan.FromSeconds(30)))
.UseFeatureFlags();
});
builder.Services.AddAzureAppConfiguration(); // for the refresh middleware
app.UseAzureAppConfiguration();
Centralized configuration store with feature-flag support, dynamic refresh (via sentinel keys), and Key Vault references for secret values. See Azure App Configuration.
Direct binding without DI
var dbOpts = builder.Configuration.GetSection("Db").Get<DbOptions>()!;
// One-shot read; no live reload
Useful in Program.cs for setup-time decisions before Build().
Configuration source-gen (.NET 8+)
Generates the binding code at compile time — replaces reflection-based binding. Required for NativeAOT.
Connection-string convention
string conn = builder.Configuration.GetConnectionString("Db")!;
// Equivalent to builder.Configuration["ConnectionStrings:Db"]
Convention; not magic. Use the pattern; tooling and analyzers expect it.
Configuration in Aspire
If you're using .NET Aspire, the AppHost passes service connection strings, endpoints, and discovery info via configuration automatically. Your service reads them with the standard IConfiguration interface — no Aspire-specific API.
Code: correct vs wrong
❌ Wrong: connection string in appsettings.json
✅ Correct: secret store
User Secrets (dev) or Key Vault / env var (prod):
Or env var:❌ Wrong: IOptions<T> for live config
✅ Correct: IOptionsMonitor<T> for live
❌ Wrong: no validation
services.Configure<DbOptions>(config.GetSection("Db"));
// Missing connection string crashes at first DB call, not startup
✅ Correct: ValidateOnStart
services.AddOptions<DbOptions>()
.Bind(config.GetSection("Db"))
.ValidateDataAnnotations()
.ValidateOnStart();
❌ Wrong: reading config in tight loop
✅ Correct: bind once, inject
Design patterns for this topic
Pattern 1 — "Bind to options class; never read flat keys outside startup"
- Intent: type-safe, validated configuration.
Pattern 2 — "ValidateOnStart for every important options class"
- Intent: fail fast on misconfiguration.
Pattern 3 — "IOptionsMonitor<T> in singletons; IOptionsSnapshot<T> in scoped"
- Intent: correct lifetime, optional live reload.
Pattern 4 — "Key Vault for secrets in production"
- Intent: no secrets on disk; managed identity for access.
Pattern 5 — "Source-gen binding for AOT"
- Intent: reflection-free; AOT-compatible.
Pros & cons / trade-offs
| Choice | Pros | Cons |
|---|---|---|
IOptions<T> | Simple; cached | No reload |
IOptionsSnapshot<T> | Per-scope freshness | Scoped only |
IOptionsMonitor<T> | Live + callback | Singleton context |
| User Secrets | Off-repo dev secrets | Dev only |
| Key Vault | Production-grade | Cost / setup |
| Azure App Config | Centralized + feature flags | Vendor; cost |
When to use / when to avoid
- Use options binding for any config beyond one or two keys.
- Use
ValidateOnStartfor important options classes. - Use User Secrets in Dev; Key Vault in Prod.
- Avoid hard-coded secrets in source.
- Avoid string-keyed lookups outside startup.
Interview Q&A
Q1. Difference between IOptions<T>, IOptionsSnapshot<T>, IOptionsMonitor<T>? IOptions — singleton, computed once. IOptionsSnapshot — scoped, per-request. IOptionsMonitor — singleton with live CurrentValue and OnChange callback.
Q2. When does appsettings.{Env}.json load? When ASPNETCORE_ENVIRONMENT matches {Env}. Loaded after the base appsettings.json, so it overrides.
Q3. Why use __ in env vars instead of :? Many shells reject : in variable names. __ is the cross-platform separator the .NET configuration system recognizes.
Q4. What does ValidateOnStart do? Runs option validators at host startup. If validation fails, the app fails to start — fast feedback over delayed runtime errors.
Q5. What's User Secrets and where do they live? A development-only secrets store, scoped per-project by UserSecretsId. Files live under %APPDATA%/Microsoft/UserSecrets/<id>/secrets.json. Loaded automatically in Development.
Q6. How do you read a config value in Program.cs before Build()? builder.Configuration["Section:Key"] or builder.Configuration.GetSection("X").Get<T>(). Already populated with all providers.
Q7. How does Azure Key Vault integrate? builder.Configuration.AddAzureKeyVault(uri, credential). Secrets become regular configuration keys; access via IConfiguration or bound options.
Q8. What's the configuration source generator? Source-gen replacement for reflection-based config binding. Enable with <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>. Required for AOT.
Q9. How would you support live reload of feature flags? Azure App Configuration with sentinel-key refresh + IOptionsMonitor<FeatureOptions>. The middleware registers refresh on each request; sentinel changes trigger refresh.
Q10. What's GetConnectionString("Name")? Convention-based shortcut for Configuration["ConnectionStrings:Name"]. Same value either way; the helper makes intent clear.
Gotchas / common mistakes
- ⚠️ Secrets in
appsettings.json— committed to git. - ⚠️
IOptions<T>for live-reloading config — never updates. - ⚠️ No
ValidateOnStart— missing config surfaces at first call, not startup. - ⚠️ Mixing
:and__in env vars —:doesn't work everywhere. - ⚠️ Reading config in hot path — bind once.
- ⚠️ Direct-bind without
Configure<T>— services don't see updates.