Skip to content

Configuration & Options

Key Points

  • IConfiguration is 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.jsonappsettings.{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> vs IOptionsSnapshot<T> vs IOptionsMonitor<T> — singleton snapshot vs per-scope reload vs change-notification.
  • Validate/ValidateOnStart catches 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:

{
  "Db": {
    "ConnectionString": "Server=...;Database=...",
    "CommandTimeoutSeconds": 60
  }
}

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

builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);

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+)

<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>

Generates the binding code at compile time — replaces reflection-based binding. Required for NativeAOT.

Connection-string convention

{
  "ConnectionStrings": {
    "Db": "...",
    "Cache": "..."
  }
}
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

{
  "Db": { "ConnectionString": "Server=...;User Id=admin;Password=plain123;" }
}

✅ Correct: secret store

User Secrets (dev) or Key Vault / env var (prod):

dotnet user-secrets set "Db:ConnectionString" "Server=...;..."
Or env var:
export Db__ConnectionString="..."

❌ Wrong: IOptions<T> for live config

public class Service(IOptions<X> opts) { /* opts.Value never updates */ }

✅ Correct: IOptionsMonitor<T> for live

public class Service(IOptionsMonitor<X> mon)
{
    public X Current => mon.CurrentValue;
}

❌ 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

var x = config["My:Setting"];   // string lookup every call

✅ Correct: bind once, inject

public class S(IOptions<MyOptions> o) { ... }

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 ValidateOnStart for 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.

Further reading