Every ASP.NET Core service binds configuration. Far fewer get the lifetime, the validation, and the reload story right. Here is the whole surface in one page, with the traps marked.
TL;DR
-
IOptions<T>= singleton, bound once, no reload, no named options. -
IOptionsSnapshot<T>= scoped, rebound per request, reloads, cannot go in a singleton. -
IOptionsMonitor<T>= singleton, live value +OnChange, works everywhere. - Add
ValidateOnStart()or your validation runs late, inside a request. -
OnChangefires multiple times per file save - debounce and dispose it.
The pattern in one screen
Bind a section, validate it, and register it - all through OptionsBuilder<T>:
public sealed class SmtpOptions
{
public const string Section = "Smtp";
[Required] public string Host { get; init; } = "";
[Range(1, 65535)] public int Port { get; init; } = 25;
public bool UseStartTls { get; init; } = true;
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}
builder.Services
.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection(SmtpOptions.Section))
.ValidateDataAnnotations()
.Validate(o => !o.UseStartTls || o.Port != 25, "StartTLS on port 25 is almost never right.")
.ValidateOnStart();
That is the whole happy path. Everything below is about the decisions this snippet hides.
Three interfaces, one table
| Interface | DI lifetime | Sees reload | Named options | Inject into singleton | Cost per resolve |
|---|---|---|---|---|---|
IOptions<T> |
Singleton | No | No | Yes | Bound once, then a field read |
IOptionsSnapshot<T> |
Scoped | Yes, per request | Yes, .Get(name)
|
No | Re-binds + re-validates each scope |
IOptionsMonitor<T> |
Singleton | Yes, live | Yes, .Get(name)
|
Yes | Cached; recomputed only on change |
The mechanical differences drive every recommendation. IOptionsSnapshot<T> re-binds the section and re-runs validation on every scope, so a heavy graph (large collections, decryption, remote lookups in a validator) is paid per request. IOptionsMonitor<T> caches in IOptionsMonitorCache<T> and only rebuilds when a change token fires.
Choosing, concretely
-
A typed settings object read inside a controller or handler →
IOptionsSnapshot<T>. One consistent value for the request, reload between requests. -
A singleton client, background service, or anything in
Program.cswiring →IOptionsMonitor<T>. It is the only reloadable one you can put there. -
A value that genuinely never changes at runtime (feature-shaped constants, framework glue) →
IOptions<T>. Cheapest, and honest about intent.
Named options
Register the same type more than once under different names, then resolve by name:
builder.Services.Configure<SmtpOptions>("primary",
builder.Configuration.GetSection("Smtp:Primary"));
builder.Services.Configure<SmtpOptions>("backup",
builder.Configuration.GetSection("Smtp:Backup"));
public sealed class MailSender(IOptionsMonitor<SmtpOptions> monitor)
{
private SmtpOptions Primary => monitor.Get("primary");
private SmtpOptions Backup => monitor.Get("backup");
}
Trap:
IOptions<T>.Valueis hard-wired toOptions.DefaultName(the empty string). InjectIOptions<SmtpOptions>against the setup above and you get an unconfigured object with no error. Named options exist only onIOptionsSnapshot<T>andIOptionsMonitor<T>.
Validation, done properly
Three layers, roughly in order of power:
-
.ValidateDataAnnotations()- attributes on the options type. -
.Validate(predicate, message)- quick cross-field rules inline. -
IValidateOptions<T>- a real class when rules get involved or need dependencies.
public sealed class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
public ValidateOptionsResult Validate(string? name, SmtpOptions o)
{
var failures = new List<string>();
if (o.Timeout < TimeSpan.FromSeconds(1))
failures.Add("Smtp:Timeout must be at least 1 second.");
if (o.Host.EndsWith(".local", StringComparison.OrdinalIgnoreCase))
failures.Add("Smtp:Host looks like a dev value.");
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}
builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();
The line people forget: ValidateOnStart
Without it, none of the above runs until the first read of .Value / .CurrentValue / .Get(). In a web app that is usually mid-request, so a bad deploy looks like a 500 to a user instead of a failed boot in your pipeline.
builder.Services.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection(SmtpOptions.Section))
.ValidateDataAnnotations()
.ValidateOnStart(); // .NET 6+
builder.Services.AddOptionsWithValidateOnStart<SmtpOptions>()
.Bind(builder.Configuration.GetSection(SmtpOptions.Section))
.ValidateDataAnnotations(); // .NET 8+ shorthand
Trimming / AOT: annotate a partial validator with
[OptionsValidator](.NET 8+) and the source generator writes theValidatebody for you - no reflection, noValidateDataAnnotationsruntime cost.
Configure, PostConfigure, and stacking
Every Configure<T> call for a given name is additive and runs in registration order. PostConfigure<T> runs after all of them - the place to enforce an invariant or apply an override that must win:
builder.Services.Configure<SmtpOptions>(config.GetSection("Smtp")); // 1
builder.Services.Configure<SmtpOptions>(o => o.Timeout = Clamp(o.Timeout)); // 2
builder.Services.PostConfigure<SmtpOptions>(o =>
{
if (builder.Environment.IsDevelopment())
o = o with { Host = "localhost" }; // last word, regardless of file
});
Gotchas grab-bag
-
Captive dependency.
IOptionsSnapshot<T>(scoped) in a singleton constructor throws under the default scope validation. In middleware, put it on theInvokeAsyncparameters; in a hosted service, injectIOptionsMonitor<T>and readCurrentValue. -
OnChangedouble-fires. One save, several FS events. Debounce (e.g. drop events within ~250 ms) and keep the returnedIDisposable- unregister it inDisposeso the callback dies with its owner. -
CurrentValuein a hot loop. It is a property that may allocate on rebuild. Read it once into a local at the top of the operation instead of per iteration. -
A bad reload can take everything down. If a reloaded value fails validation,
CurrentValuethrowsOptionsValidationExceptionon every access until the file is fixed. Reload is not free of blast radius. -
Kubernetes ConfigMaps. They mount as a swapped
..datasymlink, and the physical-file watcher frequently misses that. Test reload in-cluster; fall back to a rollout restart if it does not fire. -
Unknown keys are silent. A misspelled key in
appsettings.jsonbinds to nothing and says nothing. On .NET 8+ passo => o.ErrorOnUnknownConfiguration = truetoBindto catch it. -
GetSection().Get<T>()is not the Options pattern. It is a one-shot manual bind: no DI, no reload, no validation. Fine for values you need before the container exists; wrong for everything after.
Testing options
// IOptions<T>
var opts = Options.Create(new SmtpOptions { Host = "smtp.test", Port = 587 });
var sut = new MailSender(opts);
// IOptionsMonitor<T> - tiny fake, no package needed
public sealed class StaticMonitor<T>(T value) : IOptionsMonitor<T>
{
public T CurrentValue => value;
public T Get(string? name) => value;
public IDisposable OnChange(Action<T, string?> _) => NullDisposable.Instance;
}
Cheat sheet
- Default to
IOptionsMonitor<T>in infra code,IOptionsSnapshot<T>in request code,IOptions<T>for static settings. - Always chain
ValidateOnStart()(orAddOptionsWithValidateOnStart). - Never inject
IOptionsSnapshot<T>into a singleton or a middleware constructor. - Debounce and dispose every
OnChangeregistration. - Use
.Get(name)for named options;IOptions<T>can't see them. - Reserve
configuration.Get<T>()for pre-container bootstrap only.
Top comments (0)