AI WisdomArchitecture & guides β†—
HT
How Things Work

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.

How It Works

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.

1
Provider registration builds the configuration pipeline

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.

2
Key lookup walks the provider chain from last to first

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.

3
Hierarchical keys use ':' in code, '__' in environment variables

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.

4
IOptions<T>.BindConfiguration maps the section to a POCO

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.

5
ValidateOnStart fails the application at startup if config is invalid

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.

6
IOptionsMonitor vs IOptionsSnapshot vs IOptions

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

πŸ—‚οΈIConfiguration

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.

🌳IConfigurationRoot

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.

πŸ“ŒIOptions<T>

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.

πŸ“ΈIOptionsSnapshot<T>

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

πŸ‘οΈIOptionsMonitor<T>

Singleton. CurrentValue always reflects the latest configuration. Supports OnChange callbacks. The correct choice for Singletons that need live config reloading (feature flags, rate limits).

πŸ›‘οΈValidateOnStart

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.

🏷️Named Options

Multiple configurations of the same type: AddOptions<SmtpOptions>('primary').BindConfiguration('Smtp:Primary'). Resolve with IOptionsSnapshot<SmtpOptions>.Get('primary').

πŸ”’User Secrets

Development-only provider (ASPNETCORE_ENVIRONMENT=Development). Stores secrets.json outside the project directory, keeping credentials out of source control. Uses dotnet user-secrets set.

ASP.NET Core 9 β€” Options pattern with validation
tsx
1// appsettings.json
2{
3 "EmailService": {
4 "Host": "smtp.internal.corp",
5 "Port": 587,
6 "TimeoutMs": 5000,
7 "EnableSsl": true
8 }
9}
10
11// The strongly-typed options class
12public class EmailServiceOptions
13{
14 public const string SectionName = "EmailService";
15
16 [Required]
17 public string Host { get; set; } = default!;
18
19 [Range(1, 65535)]
20 public int Port { get; set; }
21
22 [Range(100, 30000)]
23 public int TimeoutMs { get; set; } = 5000;
24
25 public bool EnableSsl { get; set; } = true;
26}
27
28// Program.cs β€” bind and validate at startup
29builder.Services
30 .AddOptions<EmailServiceOptions>()
31 .BindConfiguration(EmailServiceOptions.SectionName)
32 .ValidateDataAnnotations()
33 .ValidateOnStart(); // ⚠️ fail fast β€” don't wait until first use
34
35// ❌ DON'T inject IConfiguration directly into services
36// This creates tight coupling to config keys (magic strings) and
37// makes the service impossible to unit test without a fake IConfiguration
38public class BadEmailService(IConfiguration config)
39{
40 // ⚠️ this is the footgun β€” magic string, breaks on rename
41 var host = config["EmailService:Host"];
42}
43
44// βœ… DO inject IOptions<T> β€” your service has no knowledge of config sources
45public class EmailService(IOptions<EmailServiceOptions> options)
46{
47 private readonly EmailServiceOptions _opts = options.Value;
48
49 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}
59
60// 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 config
66 // even after appsettings.json was changed on disk
67 return monitor.CurrentValue.Flags.GetValueOrDefault(flag);
68 }
69}
70
71// Environment variable override β€” use __ (double underscore) as separator
72// EmailService__Host=smtp.prod.corp
73// EmailService__Port=465
74// These OVERRIDE the appsettings.json values at runtime
πŸ’‘
Why This Matters

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

⚠Injecting IConfiguration directly into services couples them to the config key structure (magic strings). This makes renaming a JSON key a refactoring nightmare and makes unit testing require a fake IConfiguration. Always use IOptions<T>.
⚠IOptions<T> is a Singleton that reads config ONCE at startup. If you're using it for feature flags, rate limits, or anything that needs to change without a restart, you need IOptionsMonitor<T>. Many developers discover this 6 months after writing the code.
⚠Environment variable separator is double underscore (__), not single underscore (_). 'MySection_MyKey' is a top-level key, not a nested one. This is the most common env var configuration bug in Docker/Kubernetes deployments.
⚠ValidateDataAnnotations() without ValidateOnStart() validates lazily β€” on first use. The first HTTP request that resolves your options will get an InvalidOperationException. Add .ValidateOnStart() to fail at deployment time, not at first user request.
⚠IOptionsSnapshot<T> is Scoped β€” it cannot be injected into a Singleton service. If you need live config in a Singleton, use IOptionsMonitor<T>. This is a compile-time-invisible runtime exception: 'Cannot consume scoped service from singleton.'
⚠Named options (Get('name')) don't support ValidateDataAnnotations out of the box. You need AddOptions<T>('name').ValidateDataAnnotations() explicitly for each named instance. Easy to miss when adding a second named options instance.
Real-World Use Cases

1Production outage: config key typo silently used defaults for 3 months

Scenario

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.

Problem

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.

Solution

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

Scenario

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.

Problem

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.

Solution

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

Scenario

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.

Problem

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.

Solution

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.