Taming App Settings in .NET with the Options Pattern

See IOptions, IOptionsSnapshot, and IOptionsMonitor respond to real file changes in a runnable API with validation, named settings, and scoped reads.

You edit appsettings.json, make another request, and one service sees the new value while another still sees the old one.

That can be perfectly correct behavior. The question is which lifetime you asked the options system to give you.

Let’s build a tiny API that shows all three choices side by side. We’ll change a real file while it runs, watch a request keep its original snapshot, and read a named configuration without accepting arbitrary names.

The gotcha: “supports changes” does not mean “every configuration provider refreshes itself” or “a service automatically rebuilds its resources.”

Turn configuration into a contract

The options pattern binds configuration into a typed class. You get named properties, validation, and a dependency that’s clearer than passing configuration keys around your application.

For this example, our print service needs a branch name and a paper-quality setting.

Use the .NET 10 SDK:

Terminal window
dotnet new web -n OptionsDemo -f net10.0
Set-Location OptionsDemo

Replace appsettings.json with this complete file:

{
"Printing": { "Branch": "North", "PaperQuality": 7 },
"Printers": {
"primary": { "Branch": "Front desk", "PaperQuality": 8 },
"backup": { "Branch": "Warehouse", "PaperQuality": 5 }
}
}

Replace Program.cs with:

using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<PrintOptions>()
.BindConfiguration("Printing")
.ValidateDataAnnotations()
.Validate(options => options.PaperQuality >= 3, "PaperQuality must be at least 3.")
.ValidateOnStart();
foreach (var name in new[] { "primary", "backup" })
{
builder.Services.AddOptions<PrintOptions>(name)
.BindConfiguration($"Printers:{name}")
.ValidateDataAnnotations()
.ValidateOnStart();
}
var app = builder.Build();
var fixedValue = app.Services.GetRequiredService<IOptions<PrintOptions>>().Value;
app.Logger.LogInformation("Initial branch: {Branch}", fixedValue.Branch);
var monitor = app.Services.GetRequiredService<IOptionsMonitor<PrintOptions>>();
using var subscription = monitor.OnChange((value, name) =>
app.Logger.LogInformation("Options {Name}: branch {Branch}", name, value.Branch));
app.MapGet("/options", (
IOptions<PrintOptions> options,
IOptionsSnapshot<PrintOptions> snapshot,
IOptionsMonitor<PrintOptions> current) => new
{
fixedBranch = options.Value.Branch,
requestBranch = snapshot.Value.Branch,
currentBranch = current.CurrentValue.Branch
});
app.MapGet("/scope", async (
IOptionsSnapshot<PrintOptions> snapshot,
IOptionsMonitor<PrintOptions> current,
CancellationToken ct) =>
{
var before = snapshot.Value.Branch;
await Task.Delay(3_000, ct);
return new { before, after = snapshot.Value.Branch, live = current.CurrentValue.Branch };
});
app.MapGet("/printers/{name}", (string name, IOptionsMonitor<PrintOptions> current) =>
name is "primary" or "backup" ? Results.Ok(current.Get(name)) : Results.NotFound());
await app.RunAsync();
public sealed class PrintOptions
{
[Required]
public string Branch { get; set; } = "";
[Range(1, 10)]
public int PaperQuality { get; set; } = 7;
}

The validation attributes catch missing branch names and out-of-range quality values. The default printing configuration has an additional business rule: quality must be at least three.

ValidateOnStart makes invalid initial configuration stop startup instead of waiting for the first useful request. It isn’t a guarantee that future configuration edits will be valid.

Run the app:

Terminal window
dotnet run --no-launch-profile --urls http://localhost:5043

In another terminal:

Terminal window
Invoke-RestMethod http://localhost:5043/options

Initially, all three branch properties say North.

IOptions: one cached value

IOptions<T> supplies a value created lazily and cached for reuse. It doesn’t track later configuration reloads.

The sample deliberately reads .Value during startup so the baseline is established before you edit the file. Without that read, a first access after your edit could create its initial value from the newer configuration.

Use IOptions<T> when settings should remain stable for that service-provider lifetime. Connection setup or a startup-only decision may belong here.

It’s safe to inject the options service into a singleton. Treat the returned options object as read-only; its writable properties exist for binding, not as a shared mutable settings store for callers.

IOptionsSnapshot: once per scope and name

IOptionsSnapshot<T> is scoped. It creates and caches an options value when first accessed within that scope.

ASP.NET Core normally gives each request a scope, so a later request can see reloaded configuration while an existing request keeps its own value.

It’s not exclusively a web feature. Any correctly created dependency-injection scope can provide a snapshot. But you must not inject this scoped service into a singleton.

“Fresh per request” is useful shorthand, with two qualifications: creation happens on first access, and a provider must actually have loaded the changed configuration.

IOptionsMonitor: read the current value and observe changes

IOptionsMonitor<T> is singleton-friendly. It supports current values, named options, and change subscriptions.

When a supporting configuration source signals a change, the options system can invalidate the cached value, create a replacement, validate it, and notify listeners.

The sample keeps the OnChange subscription in a using variable around RunAsync, so it is disposed when the app stops.

Notice that the callback is synchronous and short. It logs a branch name. If reacting to a change requires substantial asynchronous work, enqueue that work in a service that owns errors and shutdown. Don’t use an async callback that silently becomes async void, or start untracked tasks for every notification.

File notifications aren’t guaranteed to arrive exactly once. Keep reactions tolerant of repeated updates.

Watch a real reload

While the app is running, change only Printing.Branch from North to South in appsettings.json. Save the file, allow the file watcher to process it, and request /options again.

You’ll see:

{
"fixedBranch": "North",
"requestBranch": "South",
"currentBranch": "South"
}

WebApplication.CreateBuilder already loads the standard JSON configuration files with reload support. We don’t add appsettings.json again after building the host.

Re-adding the same provider later can change precedence and create confusing duplicate subscriptions. Keep configuration setup before Build.

Environment variables and command-line arguments can override the JSON value. If your edit appears to do nothing, check whether a higher-priority provider is supplying that key.

Hold a request open

The /scope endpoint reads its snapshot, waits three seconds, then reads the snapshot and monitor again.

Set the branch back to North and wait for reload. Request /scope, then promptly change the branch to South from another terminal or editor.

If the file reload completes before that request finishes, the response shows North before and after for the snapshot, and South for the monitor.

That’s useful behavior when one request needs a consistent settings object. A monitor read on every line could observe two different configurations during the same operation.

If consistency matters, capture CurrentValue once at the beginning of that operation. Don’t assume several monitor reads form a transaction.

Named options need a name contract

The sample registers primary and backup, each bound to a separate section.

Try:

Terminal window
Invoke-RestMethod http://localhost:5043/printers/primary

The response contains the front-desk configuration. /printers/unknown returns 404.

That allowlist is deliberate. Calling monitor.Get with an unregistered name can construct an options object with defaults instead of telling you that your expected named registration doesn’t exist.

Named options are case-sensitive. A route parameter isn’t automatically a valid name.

IOptions<T> represents the default options instance; use the snapshot or monitor interfaces when selecting named values.

Reload isn’t a replacement strategy

A monitor tells your code about new settings. It doesn’t recreate an HttpClient, reconnect a database, or swap a running worker’s internal state for you.

Decide how a consumer adopts changes:

  • Read the current value at each independent operation.
  • Capture one value for a longer operation that needs consistency.
  • Subscribe and coordinate a resource replacement when construction depends on configuration.

For that last case, define what happens to work already using the old resource. Don’t dispose a shared client while another request still needs it.

Validation after startup has sharp edges

Try starting the app with --Printing:PaperQuality 2. The default options registration fails startup.

Live invalid changes are a separate concern. Binding and validation can fail when a snapshot or monitor creates a replacement. Don’t assume IOptionsMonitor automatically keeps the last good configuration as a safe fallback.

If uninterrupted operation requires last-known-good settings, build an explicit adoption layer: validate a candidate, publish it atomically, and retain the prior accepted configuration on failure. Also decide how operators will learn that their change was rejected.

Different providers have different refresh mechanisms. JSON can use file watching. Environment variables generally don’t update a running process’s configuration automatically. Remote configuration services may require their own refresh triggers or polling setup.

Choose the lifetime you mean

Use IOptions for a stable cached value. Use IOptionsSnapshot when a scope should keep one view while later scopes can see changes. Use IOptionsMonitor for current reads or notifications in long-lived services.

The interfaces are small. The important decision is how your application wants change to behave.

Once you can explain that behavior for a running request, a background worker, and an invalid update, picking the interface gets a lot easier. Until next time.