IOptionsSnapshot and IOptionsMonitor
Choose between IOptions, IOptionsSnapshot and IOptionsMonitor, and react safely when settings files change at run time.
public sealed class FraudScreen(IOptionsMonitor<FraudOptions> monitor)
{
public bool NeedsReview(decimal total) => total >= monitor.CurrentValue.ReviewThreshold;
}The three options interfaces differ in lifetime and in whether they see a configuration reload. Choosing the wrong one gives either stale values or a start-up error about scopes.
| Interface | Lifetime | Sees reloads | Use in |
|---|---|---|---|
IOptions<T> | Singleton | No: built once, on first .Value | Anything, when settings are fixed for the process |
IOptionsSnapshot<T> | Scoped | Yes, rebuilt once per scope (per request) | Scoped and transient services, endpoints |
IOptionsMonitor<T> | Singleton | Yes: CurrentValue is always the latest | Singletons, background services |
How a reload travels
The JSON files that CreateBuilder adds use reloadOnChange: true. A file watcher notices the write, waits 250 ms for the writer to finish, reloads the file and fires the configuration's change token. BindConfiguration registered an IOptionsChangeTokenSource<T> for that section, so the monitor clears its cached instance and calls every OnChange listener with a freshly bound one. Environment variables and command-line arguments never reload.
Snapshot or monitor
A snapshot gives one consistent value for the whole request: if pricing changes mid-request, the request still uses the old rate everywhere. The cost is a fresh bind per request for every snapshot type resolved. Being scoped, a snapshot cannot be injected into a singleton; with scope validation on (the default in Development) Build() throws Cannot consume scoped service 'IOptionsSnapshot`1[...]' from singleton, and in Production the singleton silently keeps the value bound when it was first created. In singletons use IOptionsMonitor<T> and read CurrentValue at the point of use, not in the constructor.
OnChange rules
- It can fire more than once for one save, because editors and tools write files in several steps. Make the handler idempotent.
- It runs on a thread-pool thread; publish new state with a single reference assignment.
- It returns an
IDisposable. A singleton that subscribes should dispose it. - If the reloaded values fail validation, listeners are not called and every read of
CurrentValuethrowsOptionsValidationExceptionuntil the file is fixed. Keeping the last good value fromOnChangeprotects request handling from a bad edit.
Decision and production failure modes
Most container deployments treat configuration as immutable: values come from environment variables, a change means a rollout, and IOptions<T> is enough. Reach for the monitor only for settings you really change live, such as a fraud threshold or a feature limit in a mounted ConfigMap. In Kubernetes, a ConfigMap mounted with subPath never updates, and the symlink swap the kubelet performs can slip past the file watcher; DOTNET_USE_POLLING_FILE_WATCHER=true switches to polling every four seconds. Every watched file also costs an inotify instance, and test suites that build hundreds of hosts hit the per-user limit; DOTNET_hostBuilder__reloadConfigOnChange=false turns watching off.
Example
When to use it
- Change the fraud review threshold from a mounted ConfigMap without restarting the orders service.
- Fix a singleton price calculator that never picked up a new VAT rate because it captured IOptionsSnapshot at start-up.
- Keep screening orders with the last valid threshold when someone saves an invalid value into pricing.json.
More examples
A singleton that keeps the last good value
If someone saves <code>"ReviewThreshold": -5</code>, validation fails, <code>CurrentValue</code> starts throwing <code>OptionsValidationException</code> and the listener is skipped. Reading <code>_current</code> instead keeps screening orders with the last valid threshold while the bad edit is fixed. The handler only assigns a reference, so firing twice for one save is harmless, and the subscription is disposed with the singleton.
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<FraudOptions>()
.BindConfiguration("Fraud")
.Validate(o => o.ReviewThreshold > 0, "Fraud:ReviewThreshold must be positive")
.ValidateOnStart();
builder.Services.AddSingleton<FraudScreen>();
var app = builder.Build();
app.MapPost("/orders/{id:int}/screen", (int id, decimal total, FraudScreen screen) =>
TypedResults.Ok(new { id, review = screen.NeedsReview(total) }));
app.Run();
public sealed class FraudOptions
{
public decimal ReviewThreshold { get; set; } = 1000m;
}
public sealed class FraudScreen : IDisposable
{
private readonly IDisposable? _subscription;
private volatile FraudOptions _current;
public FraudScreen(IOptionsMonitor<FraudOptions> monitor, ILogger<FraudScreen> logger)
{
_current = monitor.CurrentValue;
_subscription = monitor.OnChange(updated =>
{
// Not called when the reloaded values fail validation, so _current stays valid.
_current = updated;
logger.LogInformation("Fraud review threshold is now {Threshold}", updated.ReviewThreshold);
});
}
public bool NeedsReview(decimal orderTotal) => orderTotal >= _current.ReviewThreshold;
public void Dispose() => _subscription?.Dispose();
}Snapshot in a singleton, and the fix
Scope validation is on by default only in Development, so the same mistake in Production does not throw: the singleton receives a snapshot resolved once from the root scope and keeps it forever, which looks exactly like reload being broken. Either make the consumer scoped, as here, or keep it a singleton and switch to <code>IOptionsMonitor<T></code>. Turning on <code>ValidateScopes</code> and <code>ValidateOnBuild</code> in every environment catches it at start-up.
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<ShippingOptions>().BindConfiguration("Shipping");
// Broken: AddSingleton<ShippingQuoteService>() with this constructor makes Build() throw in
// Development: Cannot consume scoped service 'IOptionsSnapshot`1[ShippingOptions]' from singleton.
// Fix: register it as scoped, so each request gets one consistent snapshot.
builder.Services.AddScoped<ShippingQuoteService>();
var app = builder.Build();
app.MapGet("/shipping/quote", (decimal orderTotal, ShippingQuoteService quotes) =>
TypedResults.Ok(new { cost = quotes.Quote(orderTotal) }));
app.Run();
public sealed class ShippingOptions
{
public decimal FlatRate { get; set; } = 4.99m;
public decimal FreeShippingThreshold { get; set; } = 50m;
}
public sealed class ShippingQuoteService(IOptionsSnapshot<ShippingOptions> options)
{
public decimal Quote(decimal orderTotal) =>
orderTotal >= options.Value.FreeShippingThreshold ? 0m : options.Value.FlatRate;
}Live settings from a mounted ConfigMap
The app adds the file with <code>builder.Configuration.AddJsonFile("config/pricing.json", optional: true, reloadOnChange: true)</code>. The ConfigMap is mounted as a directory, not with <code>subPath</code>, because subPath mounts never receive updates. The kubelet swaps a symlink when the ConfigMap changes, typically within a minute or two, and the polling watcher notices it within about four seconds. Each replica reloads independently, so for a short time pods can disagree.
apiVersion: v1
kind: ConfigMap
metadata:
name: orders-api-pricing
data:
pricing.json: |
{ "Pricing": { "VatRate": 0.21, "FreeShippingThreshold": 50 } }
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 3
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
containers:
- name: api
image: registry.example.com/shop/orders-api:1.14.2
env:
- name: DOTNET_USE_POLLING_FILE_WATCHER
value: "true"
volumeMounts:
- name: pricing
mountPath: /app/config
readOnly: true
volumes:
- name: pricing
configMap:
name: orders-api-pricing
Discussion