DEV Community

Cover image for The .NET Options pattern: a practical field guide
Prune
Prune

Posted on

The .NET Options pattern: a practical field guide

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.
  • OnChange fires 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();
Enter fullscreen mode Exit fullscreen mode

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 handlerIOptionsSnapshot<T>. One consistent value for the request, reload between requests.
  • A singleton client, background service, or anything in Program.cs wiringIOptionsMonitor<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");
}
Enter fullscreen mode Exit fullscreen mode

Trap: IOptions<T>.Value is hard-wired to Options.DefaultName (the empty string). Inject IOptions<SmtpOptions> against the setup above and you get an unconfigured object with no error. Named options exist only on IOptionsSnapshot<T> and IOptionsMonitor<T>.

Validation, done properly

Three layers, roughly in order of power:

  1. .ValidateDataAnnotations() - attributes on the options type.
  2. .Validate(predicate, message) - quick cross-field rules inline.
  3. 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>();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Trimming / AOT: annotate a partial validator with [OptionsValidator] (.NET 8+) and the source generator writes the Validate body for you - no reflection, no ValidateDataAnnotations runtime 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
});
Enter fullscreen mode Exit fullscreen mode

Gotchas grab-bag

  • Captive dependency. IOptionsSnapshot<T> (scoped) in a singleton constructor throws under the default scope validation. In middleware, put it on the InvokeAsync parameters; in a hosted service, inject IOptionsMonitor<T> and read CurrentValue.
  • OnChange double-fires. One save, several FS events. Debounce (e.g. drop events within ~250 ms) and keep the returned IDisposable - unregister it in Dispose so the callback dies with its owner.
  • CurrentValue in 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, CurrentValue throws OptionsValidationException on every access until the file is fixed. Reload is not free of blast radius.
  • Kubernetes ConfigMaps. They mount as a swapped ..data symlink, 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.json binds to nothing and says nothing. On .NET 8+ pass o => o.ErrorOnUnknownConfiguration = true to Bind to 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;
}
Enter fullscreen mode Exit fullscreen mode

Cheat sheet

  • Default to IOptionsMonitor<T> in infra code, IOptionsSnapshot<T> in request code, IOptions<T> for static settings.
  • Always chain ValidateOnStart() (or AddOptionsWithValidateOnStart).
  • Never inject IOptionsSnapshot<T> into a singleton or a middleware constructor.
  • Debounce and dispose every OnChange registration.
  • Use .Get(name) for named options; IOptions<T> can't see them.
  • Reserve configuration.Get<T>() for pre-container bootstrap only.

Top comments (0)