Configuration looks simple. You put values in appsettings.json, read them in your app, and move on. Most developers treat it as solved infrastructure â the boring part you set up once and forget.
That's exactly why it goes wrong.
Unlike a null reference exception or a failed build, configuration mistakes are quiet. They don't announce themselves. They show up as an outage at 2 AM, a security breach traced back to a committed API key, or a production bug that disappears when you restart the container. By the time you find them, the damage is done.
These are the mistakes I've seen developers make after years of writing production code â people who knew the language well, shipped real systems, and still got this wrong. Sometimes for years without it biting them. That's the insidious part about configuration mistakes. They don't fail loudly. They wait.
The Mistakes
â Mistake 1: Storing Secrets in appsettings.json
This is the one that can end careers. Even experienced developers do it â usually under deadline pressure or because "it's just the dev environment."
{
"ConnectionStrings": {
"DefaultConnection": "Server=prod-db;Database=Orders;User=admin;Password=SuperSecret123!"
},
"Stripe": {
"SecretKey": "sk_live_abc123xyz"
}
}
The moment this file touches Git, you have a problem. Git history is forever. Even if you delete the file in the next commit, the secret is still in the history. Public repos get scraped by bots within minutes â AWS credentials especially. Attackers run up thousands of dollars in compute charges before you even notice.
But it's not just public repos. Private repos get breached. Contractors leave. CI/CD pipelines log environment details. The blast radius of a committed secret is larger than most developers realize.
â ď¸ Why it's bad:
- Secrets leak into Git history permanently
- Local dev machines become security liabilities
- Rotating secrets means changing code and redeploying
- One breach exposes every environment that uses that file
The real issue: appsettings.json is for configuration data â timeouts, feature flags, URLs. Secrets are not configuration data. They belong in a secret store.
â The right way:
For local development, use User Secrets:
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "your-connection-string"
dotnet user-secrets set "Stripe:SecretKey" "sk_test_yourkey"
User Secrets live outside your project directory and never touch source control.
For production, use environment variables or a dedicated secret store:
builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: false)
.AddUserSecrets<Program>(optional: true) // Local dev only
.AddEnvironmentVariables(); // Production secrets
Azure Key Vault, AWS Secrets Manager, and HashiCorp Vault are all production-grade options depending on your stack. The pattern is the same: secrets come from outside the codebase.
Rule: if you'd be embarrassed reading it aloud in a meeting, it doesn't belong in appsettings.json.
â Mistake 2: Binding Configuration to POCOs Incorrectly
The Options pattern is the professional way to consume configuration in .NET. Most developers know it exists. Far fewer use it correctly.
Here's what incorrect binding looks like:
// â Reading raw strings directly
var timeout = _configuration["HttpClient:TimeoutSeconds"]; // Returns null silently
var maxRetries = int.Parse(_configuration["HttpClient:MaxRetries"]); // Throws if missing
// â Binding to the wrong section
builder.Services.Configure<HttpClientSettings>(
builder.Configuration); // Bound to root, not the HttpClient section
The first returns null without warning you the key is missing. The second binds successfully but every property is null or default because you bound to the wrong section.
Here's the correct pattern:
// Your settings class
public class HttpClientSettings
{
public int TimeoutSeconds { get; set; } = 30;
public int MaxRetries { get; set; } = 3;
public string BaseUrl { get; set; } = string.Empty;
}
// appsettings.json
{
"HttpClient": {
"TimeoutSeconds": 60,
"MaxRetries": 5,
"BaseUrl": "https://api.example.com"
}
}
// â
Correct binding â section name must match the JSON key exactly
builder.Services.Configure<HttpClientSettings>(
builder.Configuration.GetSection("HttpClient"));
Then inject it correctly:
public class ApiClient
{
private readonly HttpClientSettings _settings;
// â
IOptions<T> for settings that don't change at runtime
public ApiClient(IOptions<HttpClientSettings> options)
{
_settings = options.Value;
}
}
â ď¸ Why it's bad:
- Wrong section name means every property is null or default â silently
IConfiguration["key"]Â string access gives you no type safety and no compile-time checking- Values don't update when expected in containerized environments
- Refactoring config keys breaks things at runtime, not compile time
The real issue:Â Configuration binding is unforgiving. One wrong section name and everything silently fails â no exception, no warning, just defaults. Strongly typed options with the correct section name are the only safe approach.
â Mistake 3: Not Validating Configuration at Startup
You can get every other mistake right and still blow up in production if you don't verify your configuration is correct before the app starts serving requests.
What happens without validation:
// App starts fine â
// Health checks pass â
// Deployment succeeds â
// First request hits the payment service...
public class PaymentService
{
private readonly StripeClient _client;
public PaymentService(IOptions<StripeSettings> options)
{
// StripeSettings.SecretKey is empty because the environment
// variable wasn't set on the new server
_client = new StripeClient(options.Value.SecretKey);
}
public async Task<PaymentResult> ChargeAsync(decimal amount)
{
// â Throws here, three layers deep, with a cryptic Stripe error
// not a "SecretKey is missing" error
return await _client.Charge(amount);
}
}
The app starts successfully. It passes health checks. The error message points at Stripe, not at the missing configuration. In containerized environments, the app reports healthy right up until real traffic hits it.
â The right way â fail fast and loudly:
public class StripeSettings
{
[Required]
public string SecretKey { get; set; } = string.Empty;
[Required]
[Url]
public string WebhookUrl { get; set; } = string.Empty;
[Range(1, 10)]
public int MaxRetries { get; set; } = 3;
}
// In Program.cs
builder.Services
.AddOptions<StripeSettings>()
.Bind(builder.Configuration.GetSection("Stripe"))
.ValidateDataAnnotations()
.ValidateOnStart(); // â This is the key line
With ValidateOnStart(), if SecretKey is missing or empty, the app throws an OptionsValidationException immediately at startup â before it serves a single request. The error message tells you exactly what's wrong and where.
Starting successfully is not the same as being configured correctly. Two method calls are the difference between finding out at startup and finding out in production at 2 AM.
Those are the three that cause the most damage. These next four are shorter â but I've seen every one of them take down a production environment.
Treating Configuration as a Static Snapshot
Most developers think configuration comes from one place. It doesn't. .NET merges configuration from a layered hierarchy, and the last provider wins:
appsettings.jsonappsettings.{Environment}.json- User Secrets
- Environment variables
- Command-line args
- Azure Key Vault / other providers
"It works on my machine" is almost always a layer problem. An environment variable on the server is silently overriding a value in appsettings.Production.json. A developer's User Secret is shadowing a shared team value. The wrong layer won, and you don't know which one.
When debugging, builder.Configuration.GetDebugView() shows you exactly which provider supplied each value and in what order. Use it.
Putting Logic in Configuration
Configuration is for data. Not behavior.
// â Logic disguised as configuration
{
"DiscountStrategy": "TieredPercentage",
"RetryPolicy": "ExponentialBackoff",
"AuthMode": "OAuth2WithPKCE"
}
These aren't values â they're decisions. When you put decisions in configuration, you lose compile-time safety, you create magic strings that can drift from the code that consumes them, and you build a second codebase in JSON that has no type system and no tests.
// â
Data belongs in configuration
{
"DiscountRate": 0.15,
"MaxRetryAttempts": 3,
"TokenExpiryMinutes": 60
}
Logic belongs in code. Data belongs in configuration. The line is clearer than most developers draw it.
IOptionsSnapshot in Singleton Services
This one causes bugs that are hard to diagnose â and the behavior depends on your setup.
IOptionsSnapshot<T> is scoped â it's designed to reflect configuration changes per request. If you inject it into a singleton service, modern .NET with scope validation enabled will throw an InvalidOperationException at startup. That's actually the good outcome â you find out immediately.
The dangerous scenario is when scope validation is disabled, or in older setups like BackgroundService and factory-created scopes. There, the singleton silently captures the first scoped instance it receives and holds onto it forever. Your configuration never updates. No exception. No warning. Just stale values in production.
// â Singleton capturing a scoped service
builder.Services.AddSingleton<BackgroundProcessor>();
public class BackgroundProcessor
{
// This will never reflect config changes
public BackgroundProcessor(IOptionsSnapshot<ProcessorSettings> options) { }
}
The rule:
- Singleton services âÂ
IOptionsMonitor<T> (supports change notifications) - Scoped services âÂ
IOptionsSnapshot<T> (refreshes per request) - Transient services â either works
ReloadOnChange in Containers and Cloud Environments
reloadOnChange: true sounds like a good idea. In local development, it is. In containers, Kubernetes, Azure App Service, or any read-only file system, it silently fails.
File watchers depend on file system events. Many cloud environments mount configuration files as read-only volumes that don't emit those events. Your app thinks it supports hot reload. It doesn't. Config changes don't apply until a full redeploy, and you have no idea why.
In cloud environments, use environment variables, Azure App Configuration, or Key Vault â not file watchers. Set reloadOnChange: false and be explicit about it.
Mutating IConfiguration at Runtime
This one is rare but catastrophic when it happens â usually in codebases where someone decided configuration needed to be âdynamic.â
The mistake is adding or modifying configuration providers after the host has built, typically inside middleware or a service constructor. It feels clever. It isnât.
Configuration providers are not guaranteed to be thread-safe. Modifying the configuration tree after startup can create race conditions where different requests see different values simultaneously. Reload tokens fire unpredictably. Services that cached their configuration at startup are now out of sync with services that havenât. Debugging becomes nearly impossible because the configuration state is no longer deterministic.
// â Modifying configuration after the host is built
var app = builder.Build();
app.Use(async (context, next) =>
{
// Dynamically forcing all providers to reload mid-request
context.RequestServices
.GetRequiredService<IConfigurationRoot>()
.Reload(); // Race condition: other requests may be mid-read
await next();
});
// â
If you need values that change at runtime, use IOptionsMonitor<T>
public class MyService
{
private readonly IOptionsMonitor<MySettings> _options;
public MyService(IOptionsMonitor<MySettings> options)
{
_options = options;
}
public void DoWork()
{
// Always reads the current value safely
var settings = _options.CurrentValue;
}
}
Configuration should be immutable after startup. If you need runtime-configurable values, thatâs what IOptionsMonitor
Registering Services Before Adding Providers
Order matters in Program.cs â more than most developers realize.
If you bind options or register services that depend on configuration before youâve finished adding all your providers, those services bind to an incomplete configuration tree. A later provider â Key Vault, environment variables, Azure App Configuration â overrides values after the binding has already happened. Your services donât rebind. They run with stale or missing configuration and you have no idea why.
The rule is simple: add all providers first, then bind options, then register services. Always in that order.
// â
Correct order
builder.Configuration
.AddJsonFile(...)
.AddEnvironmentVariables()
.AddAzureKeyVault(...); // All providers added first
builder.Services // Options bound after
.AddOptions<MySettings>()
.Bind(builder.Configuration.GetSection("MySettings"))
.ValidateDataAnnotations()
.ValidateOnStart();
Itâs a sequencing mistake that produces symptoms far removed from the cause â exactly the kind of bug that costs hours to diagnose.
â The Gold Standard Setup
Here's the setup used by teams who ship without configuration surprises.
// Program.cs
// 1. Provider hierarchy â order matters, last provider wins
builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: false)
.AddEnvironmentVariables() // Overrides JSON in all environments
.AddCommandLine(args); // Highest precedence â overrides everything
// User Secrets override JSON during local development.
// They are NOT automatically ignored in production â this guard is required.
if (builder.Environment.IsDevelopment())
builder.Configuration.AddUserSecrets<Program>();
// For cloud deployments, add your secrets provider here:
// builder.Configuration.AddAzureKeyVault(...);
// builder.Configuration.AddAwsSecretsManager(...);
// 2. Strongly typed options with startup validation
// Note: In .NET 8+, .BindConfiguration("Section") is equivalent to
// .Bind(builder.Configuration.GetSection("Section")) and is the newer pattern.
builder.Services
.AddOptions<DatabaseSettings>()
.Bind(builder.Configuration.GetSection("Database"))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services
.AddOptions<StripeSettings>()
.Bind(builder.Configuration.GetSection("Stripe"))
.ValidateDataAnnotations()
.ValidateOnStart();
// 3. Inject correctly based on lifetime
// Singleton â IOptionsMonitor<T>
// Scoped â IOptionsSnapshot<T>
// Transient â IOptionsSnapshot<T> (or IOptions<T> if config reload isn't needed)
Your settings classes:
public class DatabaseSettings
{
[Required]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 300)]
public int CommandTimeoutSeconds { get; set; } = 30;
}
public class StripeSettings
{
[Required]
public string SecretKey { get; set; } = string.Empty;
[Required]
[Url]
public string WebhookUrl { get; set; } = string.Empty;
}
If you want to guard against the entire section being missing â not just individual properties â add .ValidateOnStart() after .Validate() with a null check:
builder.Services
.AddOptions<StripeSettings>()
.Bind(builder.Configuration.GetSection("Stripe"))
.Validate(s => !string.IsNullOrEmpty(s.SecretKey),
"Stripe:SecretKey is missing â check environment variables or Key Vault.")
.ValidateDataAnnotations()
.ValidateOnStart();
A note on why this uses an explicit string check instead of a null check: when a section is entirely missing, Bind() still creates a default-constructed object with empty/default values. The object is never null. [Required] on string properties catches empty strings, so ValidateDataAnnotations() already covers most missing-section cases. Explicit .Validate() is for non-string properties where a default value like 0 could be ambiguous.
No magic strings. No silent nulls. No runtime surprises. The app either starts correctly configured or it doesn't start at all.
đ§ Key Takeaways
- Secrets belong in User Secrets locally, environment variables or a vault in production â never inÂ
appsettings.json - Bind to the correct section by name using strongly typed options
- Validate at startup withÂ
ValidateDataAnnotations()Â andÂValidateOnStart() - The provider hierarchy is layered â order matters and the last provider wins
- Configuration is for data, not behavior
- Match your Options interface to your service lifetime
Next steps: Find one place in your codebase where you're reading configuration with _configuration["SomeKey"]. Replace it with a strongly typed options class, bind it correctly, and add startup validation. The difference is immediate.
In the next post, we'll build a complete WebAPI controller using these patterns â and show you how to structure your endpoints so they're testable, consistent, and production-ready from day one.