IOptions, IOptionsSnapshot or IOptionsMonitor?
The options pattern has three interfaces that look almost identical. Picking the wrong one means settings that never reload, or a startup crash.
The options pattern binds a section of configuration to a class, so your code reads settings.Timeout instead of config["Inventory:Timeout"]:
public class InventoryOptions
{
public const string Section = "Inventory";
[Required, Url]
public string BaseUrl { get; set; } = "";
[Range(1, 120)]
public int TimeoutSeconds { get; set; } = 30;
}
builder.Services.AddOptions<InventoryOptions>()
.BindConfiguration(InventoryOptions.Section)
.ValidateDataAnnotations()
.ValidateOnStart();
Then you inject one of three interfaces. They differ in two ways: when the value is read, and which services can use them.
The three interfaces
| Interface | Lifetime | Sees config changes? | Use it when |
|---|---|---|---|
IOptions<T> | Singleton | No, read once | The setting never changes while the app runs |
IOptionsSnapshot<T> | Scoped | Yes, once per request | Per-request code that should pick up changes |
IOptionsMonitor<T> | Singleton | Yes, at any time | Singletons and background services that need live values |
IOptions<T>
The value is created the first time it's requested and never changes. This is right for most settings: connection details, feature limits, URLs. It's also the cheapest.
IOptionsSnapshot<T>
Re-reads the options at the start of each request, so a change to appsettings.json (or Azure App Configuration, with refresh enabled) takes effect on the next request. Because it's scoped, you can't inject it into a singleton. If you do, scope validation throws.
IOptionsMonitor<T>
A singleton that always returns the latest value through CurrentValue, and can notify you when it changes:
public class InventoryPoller(IOptionsMonitor<InventoryOptions> options) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var current = options.CurrentValue; // read each time, not once in the constructor
// ...
await Task.Delay(TimeSpan.FromSeconds(current.TimeoutSeconds), stoppingToken);
}
}
}
Reading CurrentValue once in the constructor and storing it defeats the purpose. Read it where you use it.
Validate at startup
ValidateDataAnnotations() checks the attributes on your options class, and ValidateOnStart() runs that check when the app starts instead of the first time the options are used. A missing URL then fails the deployment immediately, not at 2 a.m. when the first request hits that code path.
For rules attributes can't express, add .Validate(o => ..., "message") or implement IValidateOptions<T>.
Takeaway
Default to IOptions<T>. Use IOptionsSnapshot<T> in request-scoped code that should see changes, and IOptionsMonitor<T> in singletons that need them. Whichever you use, add ValidateOnStart() so bad configuration fails fast.