Configuration & the Options Pattern
How appsettings.json, environment variables, and IOptions<T> layer together β and why you should never read IConfiguration directly in your services.
ASP.NET Core's configuration system is a layered provider pipeline. Each source (JSON files, environment variables, command-line args, Azure Key Vault) is a provider. Providers are registered in order, and later providers override earlier ones for the same key. The Options pattern (IOptions<T>, IOptionsSnapshot<T>, IOptionsMonitor<T>) maps this flat key/value store to strongly-typed POCOs with validation, eliminating magic strings and making your services testable.
WebApplication.CreateBuilder() registers providers in order: appsettings.json β appsettings.{Environment}.json β User Secrets (Development only) β Environment Variables β Command-line arguments. Providers are evaluated last-wins: a key present in Environment Variables overrides the same key in appsettings.json.
When you read IConfiguration['EmailService:Host'], the framework asks each provider (in reverse registration order) whether it has the key. The first provider that has the key wins. This is why Environment Variables override appsettings.json β they were registered later.
JSON nesting is flattened with ':' separators: { 'EmailService': { 'Host': 'x' } } becomes key 'EmailService:Host'. Environment variables cannot contain ':', so they use '__' (double underscore) as the separator: EmailService__Host=x. The configuration system translates both to the same flat key.
AddOptions<T>().BindConfiguration('SectionName') uses reflection to map JSON keys to matching property names on T (case-insensitive). This happens once at startup for IOptions<T> (singleton) or per-request for IOptionsSnapshot<T>. The binding ignores unknown keys, so a typo in a config key will silently go unbound β which is why ValidateDataAnnotations() is essential.
Without ValidateOnStart(), validation errors surface at first use β potentially in a production request handler returning 500s. With ValidateOnStart(), the host fails to start if the options don't pass [Required] / [Range] / custom validation. This turns a runtime bug into a deployment gate.
IOptions<T>: singleton, reads config once. Never reloads. IOptionsSnapshot<T>: scoped (one per request), re-reads config each request (will pick up file changes, but only between requests). IOptionsMonitor<T>: singleton, but CurrentValue is updated whenever the source file changes β and you can register OnChange callbacks.
Key Concepts
The raw configuration abstraction. A flat key/value store. Supports hierarchical access via ':' separators. Don't inject this directly into your services β use IOptions<T> instead.
The concrete root that IConfiguration is built from. Has a Providers collection (the chain). GetDebugView() is your best friend when a config value isn't being picked up.
Singleton. Reads config once at application start. Value does not change even if appsettings.json is modified. Best for: database connection strings, fixed infrastructure config.
Scoped. Creates a new snapshot per HTTP request, reading the latest config values. Can pick up file changes between requests. Can't be injected into Singletons (lifetime mismatch).
Singleton. CurrentValue always reflects the latest configuration. Supports OnChange callbacks. The correct choice for Singletons that need live config reloading (feature flags, rate limits).
Validates options against DataAnnotations at host startup rather than first use. Turns invalid configuration from a runtime 500 into a deployment failure. Always use this.
Multiple configurations of the same type: AddOptions<SmtpOptions>('primary').BindConfiguration('Smtp:Primary'). Resolve with IOptionsSnapshot<SmtpOptions>.Get('primary').
Development-only provider (ASPNETCORE_ENVIRONMENT=Development). Stores secrets.json outside the project directory, keeping credentials out of source control. Uses dotnet user-secrets set.
1// appsettings.json2{3 "EmailService": {4 "Host": "smtp.internal.corp",5 "Port": 587,6 "TimeoutMs": 5000,7 "EnableSsl": true8 }9}1011// The strongly-typed options class12public class EmailServiceOptions13{14 public const string SectionName = "EmailService";1516 [Required]17 public string Host { get; set; } = default!;1819 [Range(1, 65535)]20 public int Port { get; set; }2122 [Range(100, 30000)]23 public int TimeoutMs { get; set; } = 5000;2425 public bool EnableSsl { get; set; } = true;26}2728// Program.cs β bind and validate at startup29builder.Services30 .AddOptions<EmailServiceOptions>()31 .BindConfiguration(EmailServiceOptions.SectionName)32 .ValidateDataAnnotations()33 .ValidateOnStart(); // β οΈ fail fast β don't wait until first use3435// β DON'T inject IConfiguration directly into services36// This creates tight coupling to config keys (magic strings) and37// makes the service impossible to unit test without a fake IConfiguration38public class BadEmailService(IConfiguration config)39{40 // β οΈ this is the footgun β magic string, breaks on rename41 var host = config["EmailService:Host"];42}4344// β DO inject IOptions<T> β your service has no knowledge of config sources45public class EmailService(IOptions<EmailServiceOptions> options)46{47 private readonly EmailServiceOptions _opts = options.Value;4849 public async Task SendAsync(string to, string subject)50 {51 using var client = new SmtpClient(_opts.Host, _opts.Port)52 {53 EnableSsl = _opts.EnableSsl,54 Timeout = _opts.TimeoutMs,55 };56 // ...57 }58}5960// IOptionsMonitor for hot-reload (no app restart needed)61public class FeatureFlagService(IOptionsMonitor<FeatureFlagOptions> monitor)62{63 public bool IsEnabled(string flag)64 {65 // .CurrentValue always returns the latest config66 // even after appsettings.json was changed on disk67 return monitor.CurrentValue.Flags.GetValueOrDefault(flag);68 }69}7071// Environment variable override β use __ (double underscore) as separator72// EmailService__Host=smtp.prod.corp73// EmailService__Port=46574// These OVERRIDE the appsettings.json values at runtime
Configuration bugs are silent until they cause production failures. A missing key falls back to a default, a typo in an env var name is ignored, and IOptions never reloads. The options pattern with ValidateOnStart() and data annotation validation turns these silent failures into deployment-time errors β which is exactly when you want to catch them.
Common Pitfalls
1Production outage: config key typo silently used defaults for 3 months
Our payment service had a TimeoutMs option for external API calls. The production environment had an env var set to 500 (half a second, very tight). Under holiday load, calls started timing out. We discovered the env var had been set as 'PaymentService_TimeoutMs' (single underscore) for 3 months β the double-underscore convention had never been documented.
Single underscore is not a valid nesting separator for ASP.NET Core environment variable configuration. The value was never bound to PaymentServiceOptions.TimeoutMs. The class had a default of 30,000 ms (30 seconds). Under normal load this was fine. Under holiday spikes, 30-second timeouts caused thread pool exhaustion and a cascade of request queuing.
Renamed to PaymentService__TimeoutMs (double underscore). Added ValidateOnStart() with [Range(100, 10000)] on TimeoutMs. Added a startup integration test that reads IOptions<PaymentServiceOptions> from a real configuration built from env vars, asserting the value matches what was set. The integration test would have caught the single-underscore error immediately.
Takeaway: Environment variable configuration separators must be double underscore (__). Document this explicitly in every runbook. Add ValidateOnStart() with range/required constraints so misconfiguration fails the deployment, not the first production request.
2Feature flag service reading stale config β IOptions<T> doesn't reload
We had a feature flag system using IOptions<FeatureFlagOptions>. We deployed a config change to enable a new checkout flow for 100% of users by updating appsettings.json. The change deployed fine but the new checkout flow never activated. We rolled back, redeploy, same result. The feature was 'off' for 4 hours until someone restarted the process.
FeatureFlagService was registered as a Singleton and injected IOptions<FeatureFlagOptions>. IOptions<T> reads the configuration once when the options object is first resolved β at application start. appsettings.json changes after startup are completely ignored by IOptions<T>. Even though the file was updated and the IConfigurationRoot had the new value, the options snapshot was frozen.
Changed injection to IOptionsMonitor<FeatureFlagOptions>. Replaced options.Value with monitor.CurrentValue in all call sites. IOptionsMonitor registers a file watcher on appsettings.json and updates CurrentValue when the file changes. Changes now propagate within ~1 second of the file being updated, with no restart required.
Takeaway: IOptions<T> is frozen at startup. For any configuration that should respond to runtime changes (feature flags, rate limits, timeouts), use IOptionsMonitor<T> in Singleton services or IOptionsSnapshot<T> in Scoped services. IOptions<T> is only appropriate for truly static configuration.
3Secret leaked to git via appsettings.Development.json
A security scan found our Stripe test API key in the public GitHub repository. It had been committed in appsettings.Development.json by a developer who didn't know about User Secrets. The key was used for test charges, but test keys give full API introspection including customer data schemas.
The developer needed the Stripe key for local development. They added it to appsettings.Development.json, which is not in .gitignore by default. It was committed and pushed. The .gitignore template for ASP.NET Core excludes appsettings.*.json only if you explicitly add that pattern β the default template only excludes appsettings.Development.json in some versions.
Revoked the key immediately. Added appsettings.Development.json to .gitignore (belts and suspenders). Set up dotnet user-secrets for all developers: dotnet user-secrets set 'Stripe:ApiKey' 'sk_test_...'. The User Secrets provider only activates when ASPNETCORE_ENVIRONMENT=Development and stores values in %APPDATA%\Microsoft\UserSecrets\{projectId}\secrets.json, outside the repo. CI/CD uses environment variables injected by the pipeline.
Takeaway: Never put secrets in appsettings.json or any file committed to source control. Use User Secrets in development (dotnet user-secrets), environment variables in CI/CD, and Azure Key Vault or similar in production. The configuration provider chain means User Secrets and env vars will always override the base appsettings.json value.