# IOptions, IOptionsSnapshot, IOptionsMonitor: when each one bites

> Three interfaces that look interchangeable and are not: when each one reads configuration, what that means for reloads, and the pitfalls that hit real codebases.

- Author: Simon Wimmesberger
- Published: 2026-06-11
- Canonical: https://blog.wimmesberger.dev/posts/options-pattern-three-flavours/
- Tags: dotnet, aspnet-core, configuration

---
Every ASP.NET Core codebase binds configuration through the options pattern, and almost every one of them picks between `IOptions<T>`, `IOptionsSnapshot<T>` and `IOptionsMonitor<T>` by habit rather than by intent. The three interfaces look interchangeable (they all hand you a `T`), but they differ in the one dimension that matters: **when the value is computed**. Pick wrong and you either serve stale settings forever or tear down a working service at startup.

Let's bind one options class and walk through what each flavour actually does.

## The setup: one class, one section

```csharp title="CheckoutOptions.cs"
public sealed class CheckoutOptions {
    [Required]
    public required string PaymentEndpoint { get; init; }

    [Range(1, 10)]
    public int MaxRetries { get; init; } = 3;
}
```

```csharp title="Program.cs"
builder.Services
    .AddOptions<CheckoutOptions>()
    .BindConfiguration("Checkout")
    .ValidateDataAnnotations()
    .ValidateOnStart();
```

With that in place, all three interfaces resolve against the same registration. The differences start at the injection site.

## IOptions&lt;T&gt;: computed once, cached forever

`IOptions<T>` is a **singleton**. The first time anything reads `.Value`, the framework binds the configuration section, caches the result, and never looks at it again:

```csharp "IOptions<CheckoutOptions>"
public sealed class CheckoutService(IOptions<CheckoutOptions> options) {
    private readonly CheckoutOptions _options = options.Value; // bound once, process-wide
}
```

This is the right default. It's allocation-free after first access, safe to inject anywhere, and honest about what most settings are: values that change on *deploy*, not at runtime. If you edit `appsettings.json` on a running instance, an `IOptions<T>` consumer will not notice, by design.

## IOptionsSnapshot&lt;T&gt;: recomputed per request

`IOptionsSnapshot<T>` is **scoped**. Each DI scope (in a web app, each HTTP request) gets a freshly bound instance, then caches it for the duration of that scope:

```csharp "IOptionsSnapshot<CheckoutOptions>"
public sealed class CheckoutEndpoint(IOptionsSnapshot<CheckoutOptions> snapshot) {
    // re-bound at the start of this request; stable within it
    public int Retries => snapshot.Value.MaxRetries;
}
```

Two things follow from "scoped". First, config edits show up on the *next request* without a restart; that's the feature. Second, you pay the binding cost once per scope, and you **cannot inject it into a singleton**. More on that below, because that's the pitfall people actually hit.

## IOptionsMonitor&lt;T&gt;: push, not pull

`IOptionsMonitor<T>` is a **singleton** that stays current. `CurrentValue` always reflects the latest configuration, and `OnChange` gives you a callback when the underlying provider reloads (for JSON files, that's the `reloadOnChange: true` file watcher, which is on by default for `appsettings.json`):

```csharp {6-7} "IOptionsMonitor<CheckoutOptions>"
public sealed class PaymentGatewayHealthCheck : IDisposable {
    private readonly IDisposable? _subscription;
    private string? _endpoint;

    public PaymentGatewayHealthCheck(IOptionsMonitor<CheckoutOptions> monitor) {
        _subscription = monitor.OnChange(opts =>
            _endpoint = opts.PaymentEndpoint);
    }

    public void Dispose() => _subscription?.Dispose();
}
```

This is the flavour for **singletons and background services** that must react to runtime changes: feature flags, log levels, circuit-breaker thresholds.

## The three pitfalls that actually happen

### Snapshot in a singleton

Inject `IOptionsSnapshot<T>` into a singleton and the container throws at startup (with scope validation on, which is the development default):

```txt wrap
System.InvalidOperationException: Cannot consume scoped service 'Microsoft.Extensions.Options.IOptionsSnapshot`1[CheckoutOptions]' from singleton 'CheckoutService'.
```

The fix is not to widen the scope. Admit the consumer is a singleton and use `IOptionsMonitor<T>`.

### Capturing .Value in the constructor

This one compiles, runs, and silently defeats the point:

```csharp del={2} ins={3}
public sealed class RetryPolicyFactory(IOptionsMonitor<CheckoutOptions> monitor) {
    private readonly CheckoutOptions _options = monitor.CurrentValue; // frozen at construction
    private CheckoutOptions Options => monitor.CurrentValue;          // reads latest
}
```

If you capture the value once, you've rebuilt `IOptions<T>` with extra steps. Keep the monitor, dereference late.

### OnChange fires more than once

The file watcher underneath `reloadOnChange` routinely raises **multiple change events for a single save**: editors write, truncate and rename in separate operations. If your callback does real work (reconnecting clients, rebuilding caches), debounce it or make it idempotent. Assume "at least once", never "exactly once".

## Validate at startup, not at first use

`ValidateOnStart()` is the single most underused line in this whole API. Without it, a missing `PaymentEndpoint` surfaces as an `OptionsValidationException` on the *first request that touches checkout*, in production, at 2 a.m. With it, the host refuses to start, your deploy rolls back, and the broken config never takes traffic. Validation belongs with the people who changed the config, not with the first user who exercises it.

## Which one do you actually want?

| You are writing…                       | Inject                | Value reflects        |
| -------------------------------------- | --------------------- | --------------------- |
| Almost anything                        | `IOptions<T>`         | Startup configuration |
| Request-scoped code that must see edits| `IOptionsSnapshot<T>` | Config at request start |
| A singleton/background service that reacts | `IOptionsMonitor<T>` | Latest, plus `OnChange` |

My rule of thumb: **start with `IOptions<T>` everywhere**, and treat every upgrade to snapshot or monitor as a design statement: "this value is expected to change at runtime, and this component is expected to care." If you can't finish that sentence for the setting in question, you didn't need the upgrade.
