1 Why Configuration Is a First-Class Architecture Concern
Configuration in ASP.NET Core has matured far beyond the simple appsettings.json file developers once sprinkled through projects. In production, configuration is a living, breathing dependency — loaded dynamically, injected safely, validated early, and evolved continuously. When configuration management is treated as an afterthought, subtle environment drift and secret sprawl can quietly undermine reliability and security. This section builds the mindset: configuration is not a static artifact but a runtime contract between your app, your environment, and your team.
1.1 Config as a Runtime Dependency, Not a File
Configuration often starts as a few key-value pairs in appsettings.json. But in production systems, configuration is an operational dependency as critical as a database connection or a cache cluster. It defines how the application behaves, where it connects, and what it exposes.
A mature system recognizes that configuration:
- Has multiple sources (JSON, environment, Key Vault, feature store).
- Evolves during runtime (via hot reload, environment overrides, or cloud refresh).
- Requires validation and observability just like code.
Example: From Static File to Dependency Graph
In development:
// appsettings.json
{
"ConnectionStrings": {
"Default": "Server=.;Database=AppDb;Trusted_Connection=True;"
}
}
In production, this becomes dynamic:
// appsettings.Production.json
{
"ConnectionStrings": {
"Default": "@Microsoft.KeyVault(SecretUri=https://prod-vault.vault.azure.net/secrets/sql-conn/)"
}
}
And you might wire it with environment detection in Program.cs:
builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
.AddEnvironmentVariables()
.AddUserSecrets<Program>(optional: true)
.AddAzureKeyVault(new Uri(builder.Configuration["KeyVaultUri"]), new DefaultAzureCredential());
At this point, configuration behaves like a dependency tree: if Key Vault is unreachable or credentials expire, the app must degrade gracefully or fail fast — not crash unexpectedly. That’s runtime dependency management, not file I/O.
1.2 Failure Modes in the Wild: Nulls, Stale Reloads, Env Drift, Secret Sprawl
Even experienced teams fall into configuration traps that only surface in production.
Null Configuration and Silent Failures
A common pitfall: unvalidated or missing configuration silently defaults to null or empty values.
Incorrect
var apiKey = configuration["Api:Key"]; // returns null if missing
client.DefaultRequestHeaders.Add("x-api-key", apiKey);
Correct
var options = builder.Configuration
.GetSection("Api")
.Get<ApiOptions>()
?? throw new InvalidOperationException("Missing Api configuration.");
if (string.IsNullOrWhiteSpace(options.Key))
throw new InvalidOperationException("API Key must be configured.");
Stale Reloads
Many assume reloadOnChange: true means all dependent services auto-update. Not true. Only components wired via IOptionsMonitor<T> or similar mechanisms receive updates. Without proper immutability boundaries, reloads can cause inconsistent states — e.g., one thread uses old settings while another uses new ones.
Environment Drift
When Dev, QA, and Prod environments use subtly different file hierarchies or variable naming (e.g., Database__ConnectionString vs. Db__ConnStr), drift accumulates. Eventually, the deployment that “worked in QA” fails in production.
Secret Sprawl
Secrets often leak across:
- JSON configs committed to source control.
- Environment variables in build servers.
- CI/CD YAML pipelines with inline credentials.
This creates compliance and operational nightmares. Production-grade systems delegate secret storage to managed identity and vault services, not to developers’ laptops.
1.3 Goals for a Production-Grade Configuration Approach
A robust configuration strategy should deliver on four pillars: safety, portability, observability, and testability.
Safety
- Secrets never exist in plaintext files.
- Configuration is validated before use.
- Reloads are atomic and consistent.
- Sensitive data is masked in logs.
Portability
- Configuration works across OS, containers, and cloud providers.
- Environment-specific overrides don’t break local development.
- Providers can be swapped without rewriting business code.
Observability
- You can trace where a value originated (provider chain).
- Configuration changes trigger structured logs or metrics.
- Health checks can detect missing or invalid configuration.
Testability
- You can replace real providers with in-memory mocks.
- Unit and integration tests validate bindings and invariants.
- CI pipelines enforce config linting or schema compliance.
Taken together, these goals let teams move configuration from an “afterthought” to a first-class design concern that scales safely under automation.
1.4 What’s New in .NET 8/9: Modern Hosting, Binding, and Validation
ASP.NET Core 8/9 introduces several features that modernize configuration handling.
Minimal Hosting Model
Configuration and dependency injection are unified under the WebApplicationBuilder API. You no longer juggle multiple startup constructs — configuration is part of the app lifecycle from the first line.
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables();
builder.Services.AddOptions<MyOptions>()
.Bind(builder.Configuration.GetSection("MySection"))
.ValidateOnStart();
Source-Generated Binding
With .NET 8+, the new ConfigurationBindingSourceGenerator eliminates reflection-heavy binding. Using [ConfigurationKeyName] attributes and source generation improves startup performance and AOT compatibility:
[ConfigurationKeyName("MySection")]
public sealed class MyOptions
{
public required string Url { get; init; }
public required int Timeout { get; init; }
}
Validation on Start
You can now enforce fail-fast behavior via ValidateOnStart() — configuration is validated before serving requests. Invalid settings crash early with clear diagnostics, preventing latent bugs.
Cloud-Native Providers
The Azure SDKs provide first-class support for:
- Azure Key Vault via
AddAzureKeyVault() - Azure App Configuration with dynamic refresh and feature flags
- Managed Identity authentication through
DefaultAzureCredential
Together, these enable “cloud-safe” defaults: configuration that adjusts automatically to the runtime environment without embedding secrets or manual wiring.
2 Configuration Fundamentals in ASP.NET Core (The Right Baseline)
Before layering advanced techniques, you need to master the baseline: the configuration pipeline, how precedence works, and how typed binding interacts with providers. ASP.NET Core’s configuration system is highly composable — but that flexibility cuts both ways if not clearly understood.
2.1 The Configuration Pipeline: Providers, Precedence, and Key Normalization
Configuration in ASP.NET Core is built from a chain of configuration providers. Each provider contributes key/value pairs into a unified IConfiguration tree. Later providers override earlier ones when the same key appears.
Provider Precedence
Typical order in WebApplication.CreateBuilder():
appsettings.jsonappsettings.{Environment}.json- User secrets (if in Development)
- Environment variables
- Command-line arguments
- Optional cloud providers (e.g., Key Vault, App Config)
The last registered source wins.
Key Normalization
All keys are normalized to a colon-delimited hierarchy (:). This ensures consistency across sources:
| Source Key | Normalized Key |
|---|---|
ConnectionStrings:Default | ConnectionStrings:Default |
ConnectionStrings__Default (env var) | ConnectionStrings:Default |
Example: Inspecting Providers
foreach (var provider in ((IConfigurationRoot)builder.Configuration).Providers)
{
Console.WriteLine(provider);
}
This diagnostic step helps you confirm the provider chain and detect misordered registrations.
2.2 Default Host Wiring
By default, ASP.NET Core uses a layered approach where each source overrides the previous:
builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
.AddUserSecrets<Program>(optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args);
Where Reload Works
- JSON files with
reloadOnChange: true: supported. - Environment variables: no automatic reload.
- Azure Key Vault: no reload unless manually configured.
- Azure App Configuration: supports refresh with sentinel keys.
Example: Environment-Specific Overrides
# Linux container
export ASPNETCORE_ENVIRONMENT=Production
export ConnectionStrings__Default="Server=db;Database=App;User Id=appuser;Password=secret"
dotnet MyApp.dll
This overrides any connection string in your JSON configs.
2.3 Binding to Types: Bind, Source Generators, and AOT Considerations
Typed configuration binding is essential for safety and discoverability.
Classic Binding
builder.Services.Configure<MyOptions>(
builder.Configuration.GetSection("MySection"));
The MyOptions class can then be injected:
public class MyService(IOptions<MyOptions> options)
{
private readonly MyOptions _opts = options.Value;
}
Source-Generated Binding (Modern)
To optimize startup and enable AOT trimming, .NET 8 introduced source-generated binding:
builder.Services.AddOptions<MyOptions>()
.BindConfiguration("MySection", binderOptions => binderOptions.BindNonPublicProperties = true)
.ValidateDataAnnotations()
.ValidateOnStart();
Under the hood, this avoids reflection and supports compile-time key validation.
Trimming/AOT Safety
If you’re publishing to native AOT (via dotnet publish -p:PublishAot=true), you must use source generators or BindRuntimeValues() patterns. Reflection-based binding (Bind()) may be trimmed away, leading to runtime errors.
2.4 Choosing Provider Order and Overrides
Provider order defines your override semantics. A misordered registration can cause subtle misbehavior.
Incorrect
builder.Configuration
.AddEnvironmentVariables()
.AddJsonFile("appsettings.json");
Here, environment variables load before the JSON file, so any appsettings.json values overwrite environment variables — the reverse of intended behavior.
Correct
builder.Configuration
.AddJsonFile("appsettings.json", false, true)
.AddEnvironmentVariables();
Custom Providers
You can implement a custom provider by extending ConfigurationProvider and ConfigurationSource. For example, pulling configuration from an internal API or encrypted blob store.
public class ApiConfigurationProvider : ConfigurationProvider
{
public override void Load()
{
var data = FetchFromApi();
Data = data.ToDictionary(k => k.Key, v => v.Value);
}
}
Custom providers should follow the same normalization rules and support reload tokens if they need dynamic refresh.
2.5 When to Split Configuration
Not all configuration belongs in the same place. Splitting improves governance and security.
1 Operational Settings
Parameters like timeouts, retries, or feature toggles. Usually reloadable and environment-specific.
2 Feature Settings
Business-level toggles that may require versioned rollout or A/B testing. Often managed via feature flags or App Configuration.
3 Secrets
API keys, connection strings, certificates — always externalized (Key Vault, AWS Secrets Manager, etc.).
4 Performance-Tuned Settings
Cache limits, queue sizes, or concurrency parameters. Usually static after deployment to avoid runtime instability.
Example: Multi-File Strategy
/appsettings.json → Safe defaults
/appsettings.Development.json → Dev-specific tools
/appsettings.Production.json → Hardened limits
/secrets.json (excluded) → Local-only secrets
This separation allows teams to safely commit configuration while ensuring secrets never enter source control.
3 The Options Pattern, Done Properly
ASP.NET Core’s Options Pattern is the backbone of safe, typed configuration access. When used correctly, it provides clear contracts, validation, and reload behavior. When misused, it can cause inconsistent state or silent misconfiguration.
3.1 IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T>
These three abstractions define when configuration values are read.
| Interface | Lifetime | Use Case |
|---|---|---|
IOptions<T> | Singleton | Static config, never changes after startup |
IOptionsSnapshot<T> | Scoped (per request) | Reload per request (e.g., web app config changes) |
IOptionsMonitor<T> | Singleton (with change notifications) | Dynamic reload for long-running services |
Example
public class MyService(IOptionsMonitor<MyOptions> monitor)
{
public void DoWork()
{
var current = monitor.CurrentValue;
// React to live changes if provider supports reload
}
}
You can also subscribe to change events:
monitor.OnChange(newValue =>
{
_logger.LogInformation("Config changed: {Timeout}", newValue.Timeout);
});
Trade-Offs
IOptions<T>: safest, simplest, best for immutable config.IOptionsSnapshot<T>: fine for short-lived scopes like HTTP requests.IOptionsMonitor<T>: powerful for background services, but requires immutable usage to avoid race conditions.
3.2 Named Options and Hierarchical Options
When your app consumes multiple similar services — e.g., multiple APIs — named options shine.
Example: Multiple APIs
builder.Services.AddOptions<ApiOptions>("Payments")
.Bind(builder.Configuration.GetSection("Apis:Payments"));
builder.Services.AddOptions<ApiOptions>("Shipping")
.Bind(builder.Configuration.GetSection("Apis:Shipping"));
Then consume them:
public class ApiClientFactory(IOptionsMonitor<ApiOptions> options)
{
public HttpClient CreateClient(string name)
{
var config = options.Get(name);
return new HttpClient { BaseAddress = new Uri(config.BaseUrl) };
}
}
This avoids brittle “stringly-typed” lookups while maintaining strong contracts.
3.3 Validation Strategies
Validation is the difference between deterministic startup and production surprises.
Built-in Validators
ValidateDataAnnotations(): validates[Required],[Range], etc.ValidateOnStart(): ensures all validations run before serving traffic.Validate(...): custom lambda validation.
builder.Services.AddOptions<PaymentOptions>()
.Bind(builder.Configuration.GetSection("Payments"))
.ValidateDataAnnotations()
.Validate(opt => opt.Timeout > 0, "Timeout must be positive")
.ValidateOnStart();
Custom Validators
For complex cross-field rules, implement IValidateOptions<T>:
public class PaymentOptionsValidator : IValidateOptions<PaymentOptions>
{
public ValidateOptionsResult Validate(string? name, PaymentOptions options)
{
if (options.MaxRetries < options.MinRetries)
return ValidateOptionsResult.Fail("MaxRetries cannot be less than MinRetries.");
return ValidateOptionsResult.Success;
}
}
Register it with DI:
builder.Services.AddSingleton<IValidateOptions<PaymentOptions>, PaymentOptionsValidator>();
Fail-fast validation turns configuration from “optional noise” into a real deployment contract.
3.4 Post-Configuration and Composition
Sometimes you need to apply computed or environment-based defaults after binding.
builder.Services.AddOptions<CacheOptions>()
.Bind(builder.Configuration.GetSection("Cache"))
.PostConfigure(options =>
{
options.DefaultTtl ??= TimeSpan.FromMinutes(10);
});
This ensures the application has sensible defaults even when the source configuration is incomplete.
Post-configuration also enables composition — layering defaults from shared libraries over app-level overrides.
3.5 Example: PaymentsOptions with Validation and Tests
public sealed class PaymentsOptions
{
[Required]
public required string ApiBaseUrl { get; init; }
[Range(1, 60)]
public int TimeoutSeconds { get; init; } = 10;
[Required]
public required string ApiKey { get; init; }
}
Registration
builder.Services.AddOptions<PaymentsOptions>()
.BindConfiguration("Payments")
.ValidateDataAnnotations()
.ValidateOnStart();
Usage
public class PaymentService(IOptions<PaymentsOptions> opts)
{
private readonly PaymentsOptions _options = opts.Value;
public async Task<bool> ChargeAsync(decimal amount)
{
using var client = new HttpClient { BaseAddress = new Uri(_options.ApiBaseUrl) };
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_options.ApiKey}");
var response = await client.PostAsync("/charge", new StringContent($"{{\"amount\":{amount}}}"));
return response.IsSuccessStatusCode;
}
}
Integration Test
[Fact]
public void Should_Validate_PaymentOptions()
{
var services = new ServiceCollection();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Payments:ApiBaseUrl"] = "https://api.payments.test",
["Payments:TimeoutSeconds"] = "5",
["Payments:ApiKey"] = "secret"
})
.Build();
services.AddOptions<PaymentsOptions>()
.BindConfiguration("Payments")
.ValidateOnStart();
var provider = services.BuildServiceProvider(validateScopes: true);
var options = provider.GetRequiredService<IOptions<PaymentsOptions>>().Value;
Assert.Equal(5, options.TimeoutSeconds);
}
This pattern — typed options with validation and test coverage — forms the foundation of production-grade configuration. Each section, provider, and reload mechanism you add builds on this baseline.
4 Reloads Without Regret
Configuration reloads are powerful but deceptively complex. They allow running applications to adapt to changes in the environment or external stores—without redeploying. But reloads also introduce synchronization risks and observability challenges. Knowing what actually reloads and how to consume it safely determines whether your app reacts gracefully or ends up in inconsistent states.
4.1 What Actually Reloads? JSON Providers, Env Vars, and Cloud Sources
Not every configuration source in ASP.NET Core supports runtime reloads. Understanding provider behavior is the first step toward building predictable systems.
JSON Files with reloadOnChange
By default, when you add JSON files like this:
builder.Configuration.AddJsonFile(
"appsettings.json", optional: false, reloadOnChange: true);
ASP.NET Core monitors the file for changes using a file watcher. When modified, the configuration provider rebuilds its in-memory representation and triggers a reload token. Components using IOptionsMonitor<T> receive updated values.
This works well for development but should be used carefully in production, as file-system watchers may not behave consistently in containerized or read-only file systems.
Environment Variables
Environment variables do not support reloads. They are static snapshots read at startup. To change them, you must restart the process or redeploy the container. This is why environment variables are suitable for stable operational configuration, not for dynamic toggles.
Azure Key Vault
The Key Vault configuration provider does not natively support automatic reload. Secrets are cached in memory. If a secret rotates, the application must either restart or use an external trigger to refresh. Some teams address this by pairing Key Vault with Azure App Configuration (which can signal refresh).
Azure App Configuration
Azure App Configuration supports dynamic reloads via sentinel keys. You register keys that act as change markers. When a sentinel changes, all dependent configuration sections refresh.
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(builder.Configuration["AppConfigConnection"])
.Select("App:*")
.ConfigureRefresh(refresh =>
{
refresh.Register("App:Sentinel", refreshAll: true)
.SetCacheExpiration(TimeSpan.FromSeconds(30));
});
});
This pattern ensures you don’t reload on every small edit but only when deliberate changes are published.
4.2 Wiring IOptionsMonitor<T> Safely: Immutability at the Edges
IOptionsMonitor<T> gives live-updating configuration, but careless use can cause race conditions or partial updates. The key is immutability at consumption boundaries.
Immutable Snapshots
When consuming monitored options, take a local snapshot instead of using CurrentValue repeatedly.
Incorrect
public async Task ProcessAsync()
{
var timeout = _optionsMonitor.CurrentValue.TimeoutSeconds;
await Task.Delay(timeout * 1000);
// May change mid-operation
}
Correct
public async Task ProcessAsync()
{
var options = _optionsMonitor.CurrentValue;
await Task.Delay(options.TimeoutSeconds * 1000);
}
Capturing CurrentValue at the start ensures atomicity of the operation. Subsequent reloads won’t affect the current transaction.
Hot-Swap Patterns
For long-lived components (e.g., background services or connection pools), you can hot-swap dependencies when configuration changes.
private IDisposable? _changeToken;
public MyService(IOptionsMonitor<DbOptions> monitor, ILogger<MyService> logger)
{
_dbClient = CreateClient(monitor.CurrentValue);
_changeToken = monitor.OnChange(opts =>
{
logger.LogInformation("Reloading DB client due to config change.");
_dbClient.Dispose();
_dbClient = CreateClient(opts);
});
}
This approach lets your app reconfigure without downtime, but note that transitions should be idempotent and thread-safe.
4.3 Race-Condition and Consistency Pitfalls
Dynamic reloads can produce transient inconsistencies if not carefully managed. Three common pitfalls stand out.
1 Partial Reads
When multiple settings change together (e.g., an API key and endpoint), reload may temporarily expose mismatched values. Use atomic sections or grouped configurations.
public record ApiOptions
{
public required string Url { get; init; }
public required string ApiKey { get; init; }
}
2 Multi-Thread Access
If your reload callback updates a shared resource, synchronize access:
private readonly object _lock = new();
monitor.OnChange(opts =>
{
lock (_lock)
{
_client = new ApiClient(opts.Url, opts.ApiKey);
}
});
3 Deferred Validation
When values reload, validation should re-run. Implement a validation method that executes in your OnChange callback to avoid silently loading invalid state.
4.4 Example: Rotating API Tokens and Circuit Breakers
Imagine an external payments API that rotates tokens every hour and allows configuration of circuit breaker thresholds via App Configuration.
public class PaymentGatewayOptions
{
public required string ApiUrl { get; init; }
public required string Token { get; init; }
public int FailureThreshold { get; init; }
}
Configuration Setup
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(builder.Configuration["AppConfigConnection"])
.Select("Payments:*")
.ConfigureRefresh(refresh =>
{
refresh.Register("Payments:Sentinel", refreshAll: true)
.SetCacheExpiration(TimeSpan.FromMinutes(5));
});
});
builder.Services.AddOptions<PaymentGatewayOptions>()
.BindConfiguration("Payments")
.ValidateOnStart();
Safe Consumption
public class PaymentClient
{
private readonly ILogger<PaymentClient> _logger;
private readonly object _sync = new();
private ApiClient _client;
public PaymentClient(IOptionsMonitor<PaymentGatewayOptions> monitor, ILogger<PaymentClient> logger)
{
_logger = logger;
_client = CreateClient(monitor.CurrentValue);
monitor.OnChange(opts =>
{
lock (_sync)
{
_logger.LogInformation("Refreshing PaymentClient due to token rotation");
_client.Dispose();
_client = CreateClient(opts);
}
});
}
private static ApiClient CreateClient(PaymentGatewayOptions opts)
=> new(opts.ApiUrl, opts.Token, opts.FailureThreshold);
}
Here, the service safely swaps the HTTP client when tokens rotate or thresholds change. The reloads are atomic and observable.
4.5 Observability: Logging Configuration Deltas Safely
Without visibility, reloads can go unnoticed until errors appear. Yet logging raw configuration can leak secrets. The balance: log deltas and metadata, not values.
Example Logging Pattern
monitor.OnChange((opts, name) =>
{
_logger.LogInformation("Configuration for {Name} reloaded at {Time}", name, DateTime.UtcNow);
_logger.LogDebug("Effective timeout: {Timeout}", opts.TimeoutSeconds);
});
Mask or omit sensitive data:
_logger.LogInformation("API key rotated for {Service}, new key masked: {Key}",
"Payments", Mask(opts.ApiKey));
Surface Effective Values
Expose current non-secret configuration via a diagnostic endpoint (secured, internal only):
app.MapGet("/_config", (IOptionsMonitor<MyOptions> opts) =>
{
var sanitized = new
{
opts.CurrentValue.BaseUrl,
opts.CurrentValue.TimeoutSeconds
};
return Results.Ok(sanitized);
});
This enables operational teams to verify effective configuration during incidents.
4.6 Testing Reload Flows with Integration Tests
You can test reload behavior without touching the file system by using ConfigurationManager and in-memory providers.
Example Test
[Fact]
public void Should_React_To_Config_Changes()
{
var configData = new Dictionary<string, string?>
{
["Service:Url"] = "https://v1.api",
["Service:Retry"] = "3"
};
var config = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.Build();
var services = new ServiceCollection();
services.AddOptions<ServiceOptions>()
.Bind(config.GetSection("Service"))
.ValidateOnStart();
var provider = services.BuildServiceProvider();
var monitor = provider.GetRequiredService<IOptionsMonitor<ServiceOptions>>();
Assert.Equal("https://v1.api", monitor.CurrentValue.Url);
// Simulate reload
configData["Service:Url"] = "https://v2.api";
(config as IConfigurationRoot)!.Reload();
Assert.Equal("https://v2.api", monitor.CurrentValue.Url);
}
Such tests validate that your reload logic and downstream consumers behave as expected before shipping to production.
4.7 Operational Guardrails
Dynamic reloads should be tightly governed. Consider these practices:
- Ownership: Only designated engineers or automation should edit reloadable config.
- Change Management: Use feature branches or App Config revision history.
- Change Windows: Avoid reloads during high-traffic hours to reduce risk.
- Auditing: Keep a log of who changed which key and when.
- Fallbacks: Implement a sentinel rollback key in App Configuration to revert a bad change quickly.
By formalizing reload governance, teams balance agility with safety.
5 Secrets: From Dev to Prod Without Leaks
Configuration is incomplete without a disciplined secret management lifecycle. Secrets—API keys, connection strings, credentials—must never be baked into code or configuration files. ASP.NET Core offers an opinionated path: local secrets in development, environment variables in CI, and managed vaults in production.
5.1 Development Secrets: dotnet user-secrets
The dotnet user-secrets tool isolates sensitive settings per developer and per project. It stores them in the user profile, not the repo.
Setup
cd MyApp
dotnet user-secrets init
dotnet user-secrets set "Payments:ApiKey" "dev-key-123"
This creates a GUID in the .csproj and a JSON file under %APPDATA%\Microsoft\UserSecrets\<guid>\secrets.json (Windows) or ~/.microsoft/usersecrets/ (Linux/macOS).
Usage
builder.Configuration
.AddUserSecrets<Program>(optional: true);
Never commit secrets to appsettings.Development.json or .env files. Local secrets stay on each developer’s machine and integrate seamlessly with configuration binding.
Never in Git
Patterns to exclude:
appsettings.Production.json
.env
*.secrets.json
Git hooks or CI scans can enforce this via regex or trufflehog checks.
5.2 Production Secrets: Azure Key Vault as a Provider
Azure Key Vault centralizes secrets with fine-grained access control and auditing. ASP.NET Core can bind directly to it.
Setup
builder.Configuration.AddAzureKeyVault(
new Uri(builder.Configuration["KeyVaultUri"]),
new DefaultAzureCredential());
Secrets in Key Vault use hierarchical naming (e.g., Payments--ApiKey) and map automatically to configuration keys (Payments:ApiKey).
Refresh Considerations
Key Vault secrets do not reload automatically. To refresh periodically, combine Key Vault with Azure App Configuration or implement a custom timer to reload the configuration root.
Secret Naming and Organization
Use a consistent schema:
| Secret Name | Purpose |
|---|---|
Sql--ConnectionString | Database access |
Payments--ApiKey | Third-party API |
Jwt--SigningKey | Token signing |
This naming allows hierarchical access via ConfigurationBinder.
5.3 AuthN to Key Vault Using Entra ID
Authentication is handled by Azure Identity via DefaultAzureCredential, which automatically selects the best available method depending on the environment.
Local Development
If signed in via Visual Studio or Azure CLI:
az login
DefaultAzureCredential uses that context to access the vault.
In Azure App Service or Functions
When deployed, the app uses Managed Identity—no credentials required. Assign the app’s identity the Key Vault Secrets User role.
az keyvault set-policy --name prod-vault \
--object-id <app-msi-object-id> \
--secret-permissions get list
This way, you never embed credentials in configuration. The same code runs unchanged across environments.
Fine-Tuning the Credential Chain
If you need explicit control:
var credentials = new DefaultAzureCredential(
new DefaultAzureCredentialOptions
{
ExcludeEnvironmentCredential = false,
ExcludeVisualStudioCredential = false,
ManagedIdentityClientId = builder.Configuration["ManagedIdentityClientId"]
});
5.4 Boot-Time Validation for Secrets
Failing fast on missing or invalid secrets prevents production incidents.
builder.Services.AddOptions<JwtOptions>()
.BindConfiguration("Jwt")
.Validate(opt => !string.IsNullOrEmpty(opt.SigningKey), "JWT signing key is required.")
.ValidateOnStart();
If Key Vault is unavailable, the app should abort startup with a descriptive error instead of running in a half-configured state.
Masking Diagnostics
Expose only the presence, not the value:
[ERROR] Missing secret: Payments:ApiKey (masked)
[INFO] Vault: prod-vault.vault.azure.net, Identity: Managed (AppId: xxxxx)
This ensures incident logs remain useful without leaking sensitive data.
5.5 Example: End-to-End Secret Flow
A realistic setup across environments:
| Environment | Secret Source | Auth Method |
|---|---|---|
| Development | dotnet user-secrets | Local user |
| CI | Environment variables (GitHub Actions, Azure Pipelines) | Secret store injection |
| Staging/Prod | Azure Key Vault | Managed Identity |
Configuration
builder.Configuration
.AddJsonFile("appsettings.json", false, true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true)
.AddUserSecrets<Program>(optional: true)
.AddEnvironmentVariables();
if (builder.Environment.IsProduction())
{
builder.Configuration.AddAzureKeyVault(
new Uri(builder.Configuration["KeyVaultUri"]),
new DefaultAzureCredential());
}
This single startup path adapts automatically across environments, achieving zero secret leakage and consistent behavior.
6 Environment-Safe Defaults and Per-Environment Overrides
Different environments—Development, Staging, Production—serve different purposes. Each needs its own configuration layer while sharing safe defaults. The goal is deterministic behavior: developers can test locally without compromising production hardening.
6.1 Designing Environment Layers
ASP.NET Core follows a layered file convention:
appsettings.json: baseline defaults (safe everywhere)appsettings.Development.json: local tools, verbose logging, mock APIsappsettings.Production.json: strict timeouts, disabled debug features
Example:
// appsettings.json
{
"Logging": { "LogLevel": { "Default": "Information" } },
"HttpClient": { "TimeoutSeconds": 10 }
}
// appsettings.Development.json
{
"HttpClient": { "TimeoutSeconds": 3 },
"Features": { "EnableDebugPage": true }
}
// appsettings.Production.json
{
"HttpClient": { "TimeoutSeconds": 15 },
"Features": { "EnableDebugPage": false }
}
This hierarchy prevents unsafe dev values from leaking into production.
6.2 A Configuration Contract for Teams and Services
Large teams often share microservices or libraries that depend on consistent configuration structures. A “configuration contract” formalizes these expectations.
Define typed settings in a shared Contracts project:
public sealed record MessagingOptions
{
public required string BrokerUrl { get; init; }
public int RetryCount { get; init; }
}
Version this contract and validate it across services. When evolving settings, prefer additive changes (new fields) to breaking removals. Teams should track configuration schema versions like API versions.
6.3 Feature Store and Operational Overrides
Azure App Configuration doubles as both centralized config and feature store. You can override app defaults in real time without redeployment.
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(builder.Configuration["AppConfigConnection"])
.Select("Common:*")
.Select("ServiceA:*");
});
To manage rollout toggles:
builder.Services.AddFeatureManagement()
.AddFeatureFilter<PercentageFilter>();
Then configure:
{
"FeatureManagement": {
"NewCheckoutFlow": {
"EnabledFor": [{ "Name": "Percentage", "Parameters": { "Value": 25 } }]
}
}
}
This lets operations teams toggle features per environment using Azure Portal or CLI.
6.4 Conditional Wiring in Program.cs
Use the environment abstraction to change behavior without branching code everywhere.
if (builder.Environment.IsDevelopment())
{
builder.Services.AddSingleton<IEmailSender, LocalEmailSender>();
}
else
{
builder.Services.AddSingleton<IEmailSender, SendGridEmailSender>();
}
Or for more advanced composition:
builder.Services.AddHttpClient("Payments", client =>
{
var opts = builder.Configuration.GetSection("Payments").Get<PaymentsOptions>();
client.BaseAddress = new Uri(opts.BaseUrl);
client.Timeout = TimeSpan.FromSeconds(opts.TimeoutSeconds);
});
Each environment provides its own section in configuration.
6.5 Example: Multi-Environment HTTP Client Policy
Define typed options for each environment:
public sealed class HttpPolicyOptions
{
public required string BaseUrl { get; init; }
public int TimeoutSeconds { get; init; }
public int RetryCount { get; init; }
}
Configuration:
// appsettings.Production.json
{
"HttpPolicy": { "BaseUrl": "https://api.prod", "TimeoutSeconds": 5, "RetryCount": 2 }
}
Registration:
builder.Services.AddHttpClient("ExternalApi")
.ConfigureHttpClient((sp, client) =>
{
var opts = sp.GetRequiredService<IOptionsMonitor<HttpPolicyOptions>>().CurrentValue;
client.BaseAddress = new Uri(opts.BaseUrl);
client.Timeout = TimeSpan.FromSeconds(opts.TimeoutSeconds);
})
.AddPolicyHandler(GetRetryPolicy());
This isolates each environment’s behavior while keeping code unchanged.
6.6 Kubernetes and Containers: Secrets and ConfigMaps
In containerized environments, configuration and secrets are injected through ConfigMaps and Secrets, then exposed as environment variables or mounted files.
env:
- name: ConnectionStrings__Default
valueFrom:
secretKeyRef:
name: db-connection
key: connection-string
ASP.NET Core automatically binds these via AddEnvironmentVariables(). The 12-factor principle—**
store config in the environment**—applies naturally.
For advanced scenarios, mount configuration as files and enable reloads:
volumeMounts:
- name: appsettings
mountPath: /app/config
volumes:
- name: appsettings
configMap:
name: app-config
And configure:
builder.Configuration.AddJsonFile("/app/config/appsettings.json", optional: false, reloadOnChange: true);
Kubernetes provides declarative updates and rollbacks, completing the end-to-end chain of environment-safe configuration.
7 Feature Flags, Typed Bindings, and Progressive Delivery
Modern software delivery emphasizes iteration without risk. Instead of all-or-nothing deployments, feature flags let teams progressively enable capabilities, toggle experiments, or disable malfunctioning components in real time. When feature flags are treated as part of configuration rather than ad-hoc conditionals, they become predictable, testable, and governable—critical for safe continuous delivery.
7.1 Why Flags Belong in Configuration: Gradual Rollout, Kill Switches, and Experiment Gates
Feature flags sit at the intersection of configuration and release management. When handled properly, they enable three high-value practices:
- Gradual Rollout: Deploy code broadly but activate new functionality incrementally—say, 5% of users per day—so you can observe performance before full exposure.
- Kill Switches: Disable a problematic component instantly without redeployment. This is vital for mitigating external dependency failures or runaway costs.
- Experimentation Gates: Drive A/B testing and behavior-based experiments without introducing new deployment artifacts.
Flags shouldn’t live in static JSON files or hard-coded conditionals. They must exist within a configuration system that supports safe reloads, centralized management, and observability.
Example of what not to do:
Incorrect
if (DateTime.UtcNow > new DateTime(2025, 10, 1))
{
EnableNewPricingPage();
}
This approach hardwires rollout logic into the code. Once deployed, it can’t be changed without redeployment.
Correct
if (_featureManager.IsEnabledAsync("NewPricingPage").Result)
{
EnableNewPricingPage();
}
The flag is externalized and controllable at runtime. This is the essence of production-grade progressive delivery.
7.2 Azure App Configuration + Microsoft.FeatureManagement
Azure App Configuration integrates naturally with the Microsoft.FeatureManagement package, providing dynamic, cloud-managed flags that update without restarts.
Registration
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(builder.Configuration["AppConfigConnection"])
.UseFeatureFlags(flagOptions =>
{
flagOptions.CacheExpirationInterval = TimeSpan.FromSeconds(30);
});
});
builder.Services.AddAzureAppConfiguration();
builder.Services.AddFeatureManagement();
Then wire it into the pipeline:
var app = builder.Build();
app.UseAzureAppConfiguration();
Common Feature Filters
Feature filters determine the conditions under which a flag activates.
- PercentageFilter: Enables a flag for a percentage of users or sessions.
- TimeWindowFilter: Activates a feature within a specific timeframe.
- TargetingFilter: Allows fine-grained control by user, group, or audience.
Example configuration in Azure App Configuration:
{
"FeatureManagement": {
"NewCheckoutFlow": {
"EnabledFor": [
{
"Name": "Percentage",
"Parameters": { "Value": 25 }
},
{
"Name": "TimeWindow",
"Parameters": {
"Start": "2025-10-16T00:00:00Z",
"End": "2025-10-20T00:00:00Z"
}
}
]
}
}
}
ASP.NET Core evaluates these filters per request. Flags are resolved at runtime, allowing immediate adjustments from the Azure portal or via REST API.
Per-Request Evaluation
Inject the IFeatureManagerSnapshot interface to check flags on a per-request basis:
public class CheckoutController(IFeatureManagerSnapshot featureManager)
{
public async Task<IActionResult> Index()
{
if (await featureManager.IsEnabledAsync("NewCheckoutFlow"))
return View("NewCheckout");
return View("LegacyCheckout");
}
}
IFeatureManagerSnapshot captures the state once per request, avoiding inconsistent results.
7.3 Designing Typed Flag “Views” for Services
Using string literals for flags is brittle and unmaintainable. A typed “view” or wrapper provides compile-time safety, discoverability, and consistency.
public interface IFeatureFlags
{
Task<bool> NewCheckoutFlowAsync();
Task<bool> EnableDiscountEngineAsync();
}
public class FeatureFlags(IFeatureManager featureManager) : IFeatureFlags
{
public Task<bool> NewCheckoutFlowAsync() => featureManager.IsEnabledAsync("NewCheckoutFlow");
public Task<bool> EnableDiscountEngineAsync() => featureManager.IsEnabledAsync("EnableDiscountEngine");
}
Now, application services depend on IFeatureFlags rather than direct string-based lookups:
if (await _flags.EnableDiscountEngineAsync())
{
ApplyPromotions();
}
This indirection pays off as flags evolve—you can rename, deprecate, or alias flags without touching business logic.
For analytics, you can extend the wrapper to emit telemetry each time a flag is evaluated, linking configuration decisions to runtime outcomes.
7.4 Live Refresh from App Configuration and Safe Fallbacks
Feature flags are only as reliable as their source. If the Azure App Configuration service becomes unreachable, your app must degrade gracefully.
Enable Refresh
Register refresh for both configuration and feature flags:
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(builder.Configuration["AppConfigConnection"])
.ConfigureRefresh(refresh =>
{
refresh.Register("App:Sentinel", refreshAll: true);
})
.UseFeatureFlags();
});
Handle Fallbacks
IFeatureManager gracefully falls back to cached values if App Configuration is offline. You can extend this behavior:
public class ResilientFeatureManager(IFeatureManager manager, ILogger<ResilientFeatureManager> logger)
{
public async Task<bool> IsEnabledAsync(string name)
{
try
{
return await manager.IsEnabledAsync(name);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Feature flag service unavailable. Defaulting to safe state for {Flag}.", name);
return false;
}
}
}
Fail-safe behavior ensures critical paths—like payments—stay stable during transient outages.
7.5 Example: Rolling Out a New Pricing Page
Consider an e-commerce app introducing a redesigned pricing page. The rollout strategy uses an Azure App Configuration feature flag and request-based percentage filter.
Setup
{
"FeatureManagement": {
"NewPricingPage": {
"EnabledFor": [{ "Name": "Percentage", "Parameters": { "Value": 10 } }]
}
}
}
Controller Integration
[FeatureGate("NewPricingPage")]
public class PricingController : Controller
{
public IActionResult Index() => View("New");
}
[FeatureGate("!NewPricingPage")]
public class LegacyPricingController : Controller
{
public IActionResult Index() => View("Legacy");
}
The FeatureGate attribute transparently routes traffic based on flag state. Once metrics indicate stability, you can raise the percentage to 100% or retire the old route entirely.
Observability
Integrate telemetry:
_logger.LogInformation("User {UserId} experienced pricing page: {Version}",
userId,
await _featureManager.IsEnabledAsync("NewPricingPage") ? "new" : "legacy");
Linking feature exposure to telemetry creates a closed feedback loop for data-driven rollout decisions.
8 Putting It All Together: A Real-World Reference Implementation
To operationalize these principles, let’s build a cohesive architecture combining configuration, secrets, validation, and feature management. This section outlines a reference implementation structured for real-world production environments.
8.1 Solution Layout and Contracts
A clean project layout separates configuration concerns clearly:
/Contracts
/Options
PaymentsOptions.cs
MessagingOptions.cs
HttpPolicyOptions.cs
/Infrastructure
/Configuration
ApiConfigurationProvider.cs
Validation
PaymentOptionsValidator.cs
/Bootstrap
Program.cs
Each Options class acts as a configuration contract. Validators live alongside the options to ensure early and localized correctness.
8.2 Program.cs Wiring (Minimal Hosting)
The entry point orchestrates provider ordering and environment-specific sources:
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", false, true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true)
.AddUserSecrets<Program>(optional: true)
.AddEnvironmentVariables();
if (builder.Environment.IsProduction())
{
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(builder.Configuration["AppConfigConnection"])
.UseFeatureFlags()
.ConfigureRefresh(refresh =>
{
refresh.Register("App:Sentinel", refreshAll: true);
});
});
builder.Configuration.AddAzureKeyVault(
new Uri(builder.Configuration["KeyVaultUri"]),
new DefaultAzureCredential());
}
builder.Services.AddAzureAppConfiguration();
builder.Services.AddFeatureManagement();
builder.Services.AddOptions<PaymentsOptions>()
.BindConfiguration("Payments")
.ValidateOnStart()
.ValidateDataAnnotations();
This unified setup yields predictable precedence and enables cloud-native reloads in production.
8.3 Strongly-Typed Config with Source-Generated Binding
Each configuration contract uses source-generated binding for AOT safety and startup performance.
[ConfigurationKeyName("Payments")]
public sealed record PaymentsOptions
{
[Required]
public required string ApiBaseUrl { get; init; }
[Required]
public required string ApiKey { get; init; }
[Range(1, 30)]
public int TimeoutSeconds { get; init; }
}
A custom validator ensures cross-field integrity:
public class PaymentOptionsValidator : IValidateOptions<PaymentsOptions>
{
public ValidateOptionsResult Validate(string? name, PaymentsOptions opts)
{
if (opts.TimeoutSeconds < 1)
return ValidateOptionsResult.Fail("Timeout must be positive.");
if (!Uri.TryCreate(opts.ApiBaseUrl, UriKind.Absolute, out _))
return ValidateOptionsResult.Fail("ApiBaseUrl is invalid.");
return ValidateOptionsResult.Success;
}
}
Validation runs on startup via ValidateOnStart(), preventing runtime surprises.
8.4 Feature Flags in Middleware and Filters
ASP.NET Core integrates feature flags seamlessly into request pipelines.
Middleware Approach
app.Use(async (context, next) =>
{
var featureManager = context.RequestServices.GetRequiredService<IFeatureManagerSnapshot>();
if (await featureManager.IsEnabledAsync("MaintenanceMode"))
{
context.Response.StatusCode = 503;
await context.Response.WriteAsync("Service under maintenance.");
return;
}
await next();
});
Attribute-Based
[FeatureGate("BetaDashboard")]
public class DashboardController : Controller
{
public IActionResult Index() => View("Beta");
}
Feature toggles can guard routes, actions, or entire controllers.
8.5 Fail-Fast Boot Pipeline
Startups should fail deterministically when configuration or secrets are invalid. Implement a bootstrap health check:
var app = builder.Build();
if (!app.Configuration.GetValue<bool>("Bootstrap:AllowUnvalidated"))
{
using var scope = app.Services.CreateScope();
var validators = scope.ServiceProvider.GetServices<IValidateOptions<PaymentsOptions>>();
foreach (var validator in validators)
{
var result = validator.Validate(null, scope.ServiceProvider.GetRequiredService<IOptions<PaymentsOptions>>().Value);
if (result.Failed)
{
throw new InvalidOperationException($"Configuration invalid: {result.FailureMessage}");
}
}
}
Add a dry-run check for external dependencies:
using var client = new HttpClient { BaseAddress = new Uri(payments.ApiBaseUrl) };
var health = await client.GetAsync("/health");
if (!health.IsSuccessStatusCode)
throw new InvalidOperationException("Payment API unreachable during boot.");
These checks enforce that deployments are only accepted in a healthy state.
8.6 Observability: Logging and Health Endpoints
For safe operations, your configuration system must emit structured events.
Configuration Change Logging
optionsMonitor.OnChange((opts, name) =>
{
_logger.LogInformation("Configuration '{Name}' reloaded at {Time}", name, DateTime.UtcNow);
});
Exposing Health Information
Expose sanitized configuration through a diagnostics endpoint:
app.MapGet("/_health/config", (IOptionsMonitor<PaymentsOptions> opts) =>
{
return Results.Ok(new
{
opts.CurrentValue.ApiBaseUrl,
Timeout = opts.CurrentValue.TimeoutSeconds
});
});
This helps SREs verify effective values in live environments without exposing sensitive data.
8.7 CI/CD Flow: Config and Secrets Across Environments
A mature pipeline moves configuration securely from Dev to Prod:
- Development: Local JSON +
dotnet user-secrets. - Build (CI): Inject environment variables from a secure store.
- Stage/Prod: Provision Azure Key Vault and App Configuration with infrastructure-as-code (Bicep/Terraform).
- Deployment: App reads only from approved sources, using Managed Identity.
- Rollback: Use App Configuration revisions or vault versioning to revert to previous states instantly.
CI pipelines should validate configuration consistency before deployment:
dotnet run -- --check-config
This invokes your ValidateOnStart() validators in headless mode.
8.8 Governance Checklist
A production-grade configuration system requires disciplined governance. Adopt these practices:
- Code Reviews for Config: Treat configuration files like source code—require PR reviews.
- Schema Enforcement: Use JSON schema or custom analyzers to detect drift early.
- CI Linting: Run automated validation on every commit (
dotnet run --check-config). - Access Controls: Restrict Key Vault and App Config write access to CI or operations teams.
- Rotation Policy: Rotate secrets automatically; never reuse between environments.
- Change Auditing: Log all configuration changes with timestamps and approvers.
- Break-Glass Procedures: Define an emergency override path for disabling dangerous features fast.
When combined, these patterns create a production-grade configuration architecture: secure, validated, observable, and adaptable. Configuration ceases to be a deployment afterthought—it becomes an integral part of your runtime resilience and delivery strategy.