Configuration and the Options Pattern in ASP.NET Core: Provider Order, User Secrets and Validation
We see the order of ASP.NET Core configuration providers with real output: appsettings, environment variables, command line, user secrets. The Options pattern, IOptions vs IOptionsSnapshot vs IOptionsMonitor, and validation with ValidateOnStart.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
The project has two settings files. appsettings.json looks like this:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Catalog": {
"PageSize": 20,
"Currency": "TRY",
"SupportEmail": "support@example.com"
}
}
appsettings.Development.json overrides just one value:
{
"Catalog": {
"PageSize": 5
}
}
You start the app in the terminal with Catalog__PageSize=50 dotnet run, and launchSettings.json sets the environment to Development. When you call an endpoint that reads Catalog:PageSize, which one comes back: 20, 5 or 50? And think about this too: what changes if you append -- --Catalog:PageSize=80 to the same command?
Configuration Is a Layer Cake
In ASP.NET Core, configuration is not a single file but a merge of sources stacked on top of each other. Each source is called a configuration provider. When the same key exists in more than one layer, the top one, meaning the one added last, wins. To see which providers WebApplication.CreateBuilder adds and in what order, temporarily put this at the top of Program.cs:
foreach (IConfigurationProvider provider in ((IConfigurationRoot)builder.Configuration).Providers)
{
Console.WriteLine(provider);
}
On .NET 6 in the Development environment the output is:
MemoryConfigurationProvider
Microsoft.Extensions.Configuration.ChainedConfigurationProvider
Microsoft.Extensions.Configuration.ChainedConfigurationProvider
JsonConfigurationProvider for 'appsettings.json' (Optional)
JsonConfigurationProvider for 'appsettings.Development.json' (Optional)
EnvironmentVariablesConfigurationProvider Prefix: ''
CommandLineConfigurationProvider
The first lines are the host's own settings (environment variables starting with ASPNETCORE_ and DOTNET_ live in there). The order we care about, from bottom to top, is: command-line arguments are the strongest, then environment variables, then appsettings.{Environment}.json, and appsettings.json at the very bottom. User secrets, which we will see shortly, slot in between the two JSON files and the environment variables in the Development environment. On .NET 10 this list is longer, but the order of these four layers is the same.
The answer to the guessing question falls out of this. appsettings.json says 20, the Development file overrides it with 5, and the environment variable overrides that with 50. Add a command-line argument and 80 wins. Whatever the top layer of the cake says, goes.
Reading Values: IConfiguration, the Colon and the Double Underscore
To access configuration directly you inject IConfiguration. In a minimal API handler it is a single line:
app.MapGet("/api/config/page-size", (IConfiguration config) => config.GetValue<int>("Catalog:PageSize"));
The nested structure in the JSON flattens into the key with :: PageSize under the Catalog section becomes Catalog:PageSize. GetValue<int> converts the string to the type you want. If you want to work with a whole section, builder.Configuration.GetSection("Catalog") gives you that subtree. Let's try the three layers in turn:
dotnet run
curl http://localhost:5080/api/config/page-size
5
Catalog__PageSize=50 dotnet run
curl http://localhost:5080/api/config/page-size
50
Catalog__PageSize=50 dotnet run -- --Catalog:PageSize=80
curl http://localhost:5080/api/config/page-size
80
In the environment variable we used __ (a double underscore) instead of :. : is not supported in environment variable names on every platform; __ works everywhere, and ASP.NET Core turns it into : when reading it. This is the form you will use most when passing settings in Docker and Kubernetes.
User Secrets: Keep Passwords Out of the Repo
Imagine we fetch product prices from an external service and that requires an API key. If you write that key into appsettings.json, it lands in the repo with the next commit. For the development environment there is the user secrets tool:
dotnet user-secrets init
dotnet user-secrets set "Catalog:PricingApiKey" "dev-only-key-123"
dotnet user-secrets list
Catalog:PricingApiKey = dev-only-key-123
The init command adds a UserSecretsId to the csproj:
<UserSecretsId>7267b694-9024-4688-a01c-651f9d1b771c</UserSecretsId>
The values are written to your user profile, not to the project folder: ~/.microsoft/usersecrets/<UserSecretsId>/secrets.json on macOS and Linux, %APPDATA%\Microsoft\UserSecrets\<UserSecretsId>\secrets.json on Windows. Look at the provider list again and you will see a new line in Development:
JsonConfigurationProvider for 'appsettings.Development.json' (Optional)
JsonConfigurationProvider for 'secrets.json' (Optional)
EnvironmentVariablesConfigurationProvider Prefix: ''
Two important notes. User secrets are loaded only in the Development environment; run the same app in Production and the key is not there. And do not let the name fool you: the file is not encrypted, it just stays away from the repo. Secrets in Production come from environment variables or from a secret store such as Azure Key Vault; we will come back to that in the security post.
The Options Pattern: Binding Settings to a Typed Class
Sprinkling string keys like "Catalog:PageSize" all over the code is fragile. A typo slips past the compiler. The Options pattern binds a configuration section to a plain class:
using System.ComponentModel.DataAnnotations;
namespace ProductCatalog.Options;
public class CatalogOptions
{
public const string SectionName = "Catalog";
[Range(1, 100)]
public int PageSize { get; set; } = 10;
[Required]
public string Currency { get; set; } = string.Empty;
[EmailAddress]
public string SupportEmail { get; set; } = string.Empty;
public string PricingApiKey { get; set; } = string.Empty;
}
The simplest form of the registration is one line:
builder.Services.Configure<CatalogOptions>(builder.Configuration.GetSection(CatalogOptions.SectionName));
Now a class that needs the settings does not have to know string keys; it injects IOptions<CatalogOptions> or one of its siblings. Let's write a controller:
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using ProductCatalog.Options;
namespace ProductCatalog.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SettingsController : ControllerBase
{
private readonly IOptions<CatalogOptions> _options;
private readonly IOptionsSnapshot<CatalogOptions> _snapshot;
private readonly IOptionsMonitor<CatalogOptions> _monitor;
public SettingsController(
IOptions<CatalogOptions> options,
IOptionsSnapshot<CatalogOptions> snapshot,
IOptionsMonitor<CatalogOptions> monitor)
{
_options = options;
_snapshot = snapshot;
_monitor = monitor;
}
[HttpGet]
public IActionResult Get()
{
CatalogOptions current = _snapshot.Value;
return Ok(new
{
current.PageSize,
current.Currency,
current.SupportEmail,
// Never return the secret itself, only whether it exists
HasPricingApiKey = current.PricingApiKey.Length > 0
});
}
[HttpGet("compare")]
public IActionResult Compare()
{
return Ok(new
{
options = _options.Value.PageSize,
snapshot = _snapshot.Value.PageSize,
monitor = _monitor.CurrentValue.PageSize
});
}
}
curl http://localhost:5080/api/settings
{"pageSize":5,"currency":"TRY","supportEmail":"support@example.com","hasPricingApiKey":true}
Send the same request in the Production environment and pageSize goes back to 20 and hasPricingApiKey becomes false; user secrets are not active there.
IOptions, IOptionsSnapshot and IOptionsMonitor
The difference between the three interfaces shows up when the settings file changes while the app is running. JSON providers watch the file by default. With the app running in the Production environment, call the compare endpoint, then change PageSize in appsettings.json to 30, save, and call it again:
curl http://localhost:5080/api/settings/compare
{"options":20,"snapshot":20,"monitor":20}
curl http://localhost:5080/api/settings/compare
{"options":20,"snapshot":30,"monitor":30}
IOptions<T> is a singleton; its value is computed the first time it is read and does not change until the app restarts. IOptionsSnapshot<T> is scoped; it reads the current value at the start of each request and keeps it fixed for the rest of that request. IOptionsMonitor<T> is a singleton too, but CurrentValue always gives you the latest value, and you can subscribe to changes with OnChange.
The rule from the lifetime post applies here as well. If you need the current settings inside a singleton service, use IOptionsMonitor<T>; IOptionsSnapshot<T> is scoped, so it cannot be injected into a singleton. If the settings will not change during the app's lifetime, IOptions<T> is the simplest and cheapest option.
Options Validation: An App That Refuses to Start with Bad Settings
The [Range] and [EmailAddress] attributes we put on CatalogOptions do not run by themselves. You need to set up the registration with AddOptions and ask for validation explicitly:
builder.Services.AddOptions<CatalogOptions>()
.Bind(builder.Configuration.GetSection(CatalogOptions.SectionName))
.ValidateDataAnnotations()
.Validate(o => o.Currency.Length == 3, "Currency must be a 3-letter ISO code.")
.ValidateOnStart();
ValidateDataAnnotations turns the attributes on, and Validate takes a lambda for rules you cannot express with an attribute. ValidateOnStart is available in .NET 6 and moves the check to the moment the app starts. Let's break three values on purpose and start it:
Catalog__PageSize=500 Catalog__Currency=TL Catalog__SupportEmail=support dotnet run
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'CatalogOptions' members: 'PageSize' with the error: 'The field PageSize must be between 1 and 100.'.; DataAnnotation validation failed for 'CatalogOptions' members: 'SupportEmail' with the error: 'The SupportEmail field is not a valid e-mail address.'.; Currency must be a 3-letter ISO code.
The app never started, and all three errors are in a single message. Remove ValidateOnStart and the app starts without complaint; the error only blows up the first time someone touches the options' Value. In our example that means the first request to /api/settings comes back with 500 Internal Server Error. An app deployed with bad settings crashing at startup is always better than it crashing on a user's request in the middle of the night.
Common Pitfalls
First, a typo in the section name. Write GetSection("Catalgo") and you get no exception at all; an empty section is bound and the class's default values are used:
{"pageSize":10,"currency":"","supportEmail":"","hasPricingApiKey":false}
With validation on, the [Required] field would have caught this right away. That is why putting at least one required field in your options classes is a good habit.
Second, injecting IOptionsSnapshot<T> into a singleton. In the Development environment the app refuses to start with this error:
Cannot consume scoped service 'Microsoft.Extensions.Options.IOptionsSnapshot`1[ProductCatalog.Options.CatalogOptions]' from singleton 'ProductCatalog.Options.ShippingCalculator'.
Third, writing secrets into appsettings.json or appsettings.Development.json. Both live in the repo. Deleting a key that made it into Git history is not enough; you have to revoke it and issue a new one.
Fourth, trying to pass the environment variable as Catalog:PageSize. Shells such as Bash do not accept : in a variable name, and the value never reaches the app. Always use __ for hierarchical keys.
Fifth, assuming IOptions<T> will see file changes. As you saw, it does not; if you expect a settings change in production to take effect without a restart, you need IOptionsSnapshot<T> or IOptionsMonitor<T>.
A Small Experiment
★☆☆ Create a file named appsettings.Production.json and set PageSize to 40 in it. Run the app in the Development and Production environments separately and compare the result of /api/config/page-size.
★★☆ Add a property called MaxPrice to CatalogOptions and write a rule with Validate that checks two fields together, such as "MaxPrice must be greater than PageSize". Pass a value that breaks the rule from the command line and read the error message.
★★★ Write a singleton CurrencyFormatter service. Have it take IOptionsMonitor<CatalogOptions> in its constructor, subscribe to changes with OnChange, and write a line to the console when the currency changes. With the app running, change Currency in appsettings.json and watch the console.
We said "write a line to the console," but Console.WriteLine is not the right way to leave a trail in a real application. In the next post we will make what the app does truly readable with ILogger<T>, log levels and filters, structured logging and Serilog. We will also control log levels from the Logging section you saw today.