IOptionsSnapshot and IOptionsMonitor

Choose between IOptions, IOptionsSnapshot and IOptionsMonitor, and react safely when settings files change at run time.

Syntaxpublic 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.

InterfaceLifetimeSees reloadsUse in
IOptions<T>SingletonNo: built once, on first .ValueAnything, when settings are fixed for the process
IOptionsSnapshot<T>ScopedYes, rebuilt once per scope (per request)Scoped and transient services, endpoints
IOptionsMonitor<T>SingletonYes: CurrentValue is always the latestSingletons, 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 CurrentValue throws OptionsValidationException until the file is fixed. Keeping the last good value from OnChange protects 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · csharp
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&lt;T&gt;</code>. Turning on <code>ValidateScopes</code> and <code>ValidateOnBuild</code> in every environment catches it at start-up.

Example · csharp
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.

Example · yaml
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

  • Be the first to comment on this lesson.