The Options Pattern
Bind a configuration section to a class, choose between IOptions, IOptionsSnapshot and IOptionsMonitor, and validate at startup.
IServiceCollection Configure<TOptions>(this IServiceCollection services, IConfiguration config) where TOptions : classSettings such as a connection string or a retry count come from configuration: JSON files, environment variables, command-line arguments. Reading them as loose strings scatters key names and parsing all over the code. The options pattern replaces that with one class per group of settings, filled in once and injected where it is needed.
Binding
Write a plain class with public read-write properties, one per setting. Binding is copying values from a configuration section into that object, matching property names to keys. services.Configure<MailOptions>(config.GetSection("Mail")) registers the binding. The options class must be non-abstract and have a public parameterless constructor, and fields are not bound, only properties.
Classes do not receive MailOptions directly. They receive a wrapper and read its value.
The three accessors
| Type | Lifetime | Reads later changes |
|---|---|---|
IOptions<T> | Singleton | No |
IOptionsSnapshot<T> | Scoped | Yes, recomputed per scope; read from Value |
IOptionsMonitor<T> | Singleton | Yes, read from CurrentValue |
Use IOptions<T> unless the setting must change while the app runs. Use IOptionsMonitor<T> inside a singleton, because IOptionsSnapshot<T> is scoped and injecting it into a singleton is a captive dependency.
Validation
Bad configuration should stop the application, not the first request that touches it. AddOptions<T>().Bind(section).ValidateDataAnnotations().ValidateOnStart() checks the attributes on the class while the host starts and throws OptionsValidationException if they fail.
Example
// Packages: Microsoft.Extensions.Configuration,
// Microsoft.Extensions.DependencyInjection,
// Microsoft.Extensions.Options.ConfigurationExtensions
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
// Stands in for appsettings.json. A colon separates nested keys.
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Mail:FromAddress"] = "[email protected]",
["Mail:RetryCount"] = "3"
})
.Build();
ServiceCollection services = new ServiceCollection();
// Bind the "Mail" section to MailOptions and register it for injection.
services.Configure<MailOptions>(config.GetSection("Mail"));
using ServiceProvider provider = services.BuildServiceProvider();
// IOptions<T> is a wrapper. The bound object is on its Value property.
IOptions<MailOptions> options = provider.GetRequiredService<IOptions<MailOptions>>();
MailOptions mail = options.Value;
Console.WriteLine($"from: {mail.FromAddress}");
Console.WriteLine($"retries: {mail.RetryCount}");
// Public read-write properties. "3" became the int 3 during binding.
class MailOptions
{
public string FromAddress { get; set; } = "";
public int RetryCount { get; set; }
}
// Output:
// from: [email protected]
// retries: 3When to use it
- Move a connection string and timeout out of code into appsettings.json and inject them as one object.
- Fail a deployment at startup when a required API key is missing from the environment.
- Let an operator change a retry limit in a config map without restarting the service.
- Bind several identically shaped tenant sections to one settings class.
More examples
Validated at startup, shorthand and longhand
Reach for this on every options class that holds something the app cannot run without. ValidateDataAnnotations comes from the Microsoft.Extensions.Options.DataAnnotations package and reads the attributes on the properties; Validate takes any predicate for rules that span two properties. With ValidateOnStart, a missing FromAddress fails the deployment with an OptionsValidationException instead of failing a customer's request.
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Longhand: bind, then switch validation on, then move it to startup.
builder.Services
.AddOptions<MailOptions>()
.Bind(builder.Configuration.GetSection(MailOptions.SectionName))
.ValidateDataAnnotations()
.Validate(o => o.RetryCount <= 10, "RetryCount must be 10 or less.")
.ValidateOnStart();
// Shorthand for the same thing: AddOptionsWithValidateOnStart<T>() is
// AddOptions<T>() with ValidateOnStart() already applied.
// builder.Services
// .AddOptionsWithValidateOnStart<MailOptions>()
// .Bind(builder.Configuration.GetSection(MailOptions.SectionName))
// .ValidateDataAnnotations();
builder.Build().Run();
public class MailOptions
{
public const string SectionName = "Mail";
[Required]
[EmailAddress]
public string FromAddress { get; set; } = "";
[Range(1, 10)]
public int RetryCount { get; set; }
}Reading a setting that changes while the app runs
Use the monitor when a setting must be changeable without a restart, such as a feature toggle or a limit you tune under load. Read CurrentValue at the moment you need it rather than caching it in a field, or you are back to the value from startup. Change notifications only arrive from file-based providers, and some container and network file systems do not report changes at all.
using System;
using Microsoft.Extensions.Options;
// A singleton. IOptionsSnapshot<T> is scoped, so it cannot be injected here.
// IOptionsMonitor<T> is a singleton and always reports the current value.
public sealed class RateLimiter : IDisposable
{
private readonly IOptionsMonitor<MailOptions> _monitor;
private readonly IDisposable? _subscription;
public RateLimiter(IOptionsMonitor<MailOptions> monitor)
{
_monitor = monitor;
// Called again each time the bound file changes on disk.
_subscription = monitor.OnChange(
(updated, name) => Console.WriteLine($"retries now {updated.RetryCount}"));
}
public int Allowance => _monitor.CurrentValue.RetryCount;
public void Dispose() => _subscription?.Dispose();
}Two sections, one options class
Named options avoid writing one near-identical class per section when several sections have the same shape, which is common for per-tenant or per-feature settings. IOptions<T> does not support names; use IOptionsSnapshot<T> or IOptionsMonitor<T> and call Get(name). Passing the wrong name returns a default-valued object rather than throwing, so a typo looks like a disabled feature.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
// appsettings.json:
// "Features": {
// "Personalize": { "Enabled": true, "ApiKey": "abc" },
// "WeatherStation": { "Enabled": false, "ApiKey": "def" }
// }
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<FeatureOptions>(
"Personalize", builder.Configuration.GetSection("Features:Personalize"));
builder.Services.Configure<FeatureOptions>(
"WeatherStation", builder.Configuration.GetSection("Features:WeatherStation"));
builder.Build().Run();
public class FeatureOptions
{
public bool Enabled { get; set; }
public string ApiKey { get; set; } = "";
}
public class FeatureGate(IOptionsSnapshot<FeatureOptions> options)
{
// Get(name) picks the named registration. Names are case-sensitive.
public bool PersonalizeOn => options.Get("Personalize").Enabled;
}
Discussion