Configuration and the options pattern
Where a configuration value comes from, which source wins, and the difference between IOptions, IOptionsSnapshot and IOptionsMonitor.
After this lesson you can
- Name the default configuration sources and their precedence
- Map an environment variable name to a configuration key
- Choose between IOptions, IOptionsSnapshot and IOptionsMonitor
- Make bad configuration fail at startup instead of on first use
IConfiguration is a flat set of string keys and string values. Nesting
is spelled with :, so this JSON
{ "Payments": { "TimeoutSeconds": 30, "Hosts": ["a", "b"] } }
becomes Payments:TimeoutSeconds = "30", Payments:Hosts:0 = "a" and
Payments:Hosts:1 = "b". Keys are case-insensitive.
Sources and precedence
WebApplication.CreateBuilder adds these sources, in this order:
appsettings.jsonappsettings.{Environment}.json- user secrets, in the Development environment only
- environment variables
- command-line arguments
When two sources define the same key, the one added later wins. The
environment name comes from ASPNETCORE_ENVIRONMENT (or
DOTNET_ENVIRONMENT) and is Production when neither is set. Because
: is not allowed in environment variable names on every platform, a
double underscore stands for it: Payments__TimeoutSeconds sets
Payments:TimeoutSeconds. The model below stands in for the
configuration providers with one case-insensitive dictionary, filled
source by source.
public static class Solution{ public static List<string> Run() { var config = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); void Add(string source, Dictionary<string, string> values) { foreach (var (key, value) in values) config[key.Replace("__", ":")] = value; } Add("appsettings.json", new() { ["Payments:TimeoutSeconds"] = "30", ["Payments:ApiBase"] = "https://pay.example" }); Add("appsettings.Production.json", new() { ["payments:timeoutseconds"] = "45" }); Add("environment", new() { ["Payments__ApiBase"] = "https://pay-eu.example" }); return new List<string> { "TimeoutSeconds=" + config["Payments:TimeoutSeconds"], "ApiBase=" + config["PAYMENTS:APIBASE"], "keys=" + config.Count, }; }}The options pattern
Rather than reading strings out of IConfiguration everywhere, bind a
section to a class once:
public class PaymentOptions
{
[Range(1, 120)] public int TimeoutSeconds { get; set; }
[Required] public string ApiBase { get; set; } = "";
}
builder.Services.AddOptions<PaymentOptions>()
.Bind(builder.Configuration.GetSection("Payments"))
.ValidateDataAnnotations()
.ValidateOnStart();
Then inject one of three interfaces:
IOptions<T>is a singleton. ItsValueis built on first access and never changes, even if the file is edited.IOptionsSnapshot<T>is scoped. It is rebuilt for each request, and stays fixed for the rest of that request. It cannot be injected into a singleton.IOptionsMonitor<T>is a singleton whoseCurrentValuereflects the latest reload, withOnChangeto react to one.
appsettings.json is loaded with reload-on-change enabled, so the
difference shows up when the file is edited while the app runs. The model
below reloads in the middle of a request.
public static class Solution{ public static List<string> Run() { var file = new Dictionary<string, string> { ["Payments:TimeoutSeconds"] = "30" }; int Bind() => int.Parse(file["Payments:TimeoutSeconds"]); var options = new Lazy<int>(Bind); // IOptions<T>: built once int? monitorCache = null; // IOptionsMonitor<T>: cached until a reload int Monitor() => monitorCache ??= Bind(); void Reload(string value) { file["Payments:TimeoutSeconds"] = value; monitorCache = null; } var log = new List<string>(); void Request(string name, Action? midway = null) { int? snapshot = null; // IOptionsSnapshot<T>: one per request int Snapshot() => snapshot ??= Bind(); log.Add($"{name} start: options={options.Value} snapshot={Snapshot()} monitor={Monitor()}"); midway?.Invoke(); log.Add($"{name} end: options={options.Value} snapshot={Snapshot()} monitor={Monitor()}"); } Request("request 1", () => Reload("60")); Request("request 2"); return log; }}Common mistakes
- Expecting
IOptions<T>to pick up an editedappsettings.json. It was computed once; useIOptionsMonitor<T>orIOptionsSnapshot<T>. - Misspelling a section or property name. The binder ignores keys with
no matching property and leaves the property at its default, so the
typo produces a silent
0or empty string rather than an error. - Writing
Payments:TimeoutSecondsas an environment variable name in a container or CI system. UsePayments__TimeoutSeconds. - Validating without
ValidateOnStart(). Validation then runs when.Valueis first read, so a bad deploy starts fine and fails on the first request that needs the setting.
What interviewers ask
- A setting is in
appsettings.json,appsettings.Production.jsonand an environment variable. Which value does the app see? The environment variable: later sources override earlier ones. IOptionsversusIOptionsSnapshotversusIOptionsMonitor? Lifetimes (singleton, scoped, singleton), when each recomputes, and that a singleton cannot take a snapshot.- Validation (data annotations or ) plus .
Exercise · Pro
Resolve keys across configuration sources
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade