Options pattern in .NET

Introduction
configuration["Smtp:Host"] compiles fine, works fine, and then breaks silently the day someone renames the config section and nothing catches it until a null reference shows up in production. The Options pattern replaces magic string lookups with a typed class bound once from configuration - and gives you a choice of three interfaces depending on whether that configuration can change while the app is running.
🎬 Watch the full video here:
Binding configuration to a typed class
Instead of reading individual keys wherever they're needed, define a class shaped like the configuration section and bind it once at startup:
public class SmtpOptions
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public string FromAddress { get; set; } = string.Empty;
}
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
Every consumer now injects IOptions<SmtpOptions> instead of IConfiguration, gets full IntelliSense on the properties, and a typo in a property name is a compile error instead of a silent runtime null.
IOptions<T> vs IOptionsSnapshot<T> vs IOptionsMonitor<T>
These three exist because "how often can this configuration value change" is a real design question, not a detail to ignore:
IOptions<T>- resolved once, as a singleton, for the app's lifetime. Cheapest, but a config file change after startup is never picked up without a restart.IOptionsSnapshot<T>- re-read once per scope (per HTTP request in a web app). Picks up config changes between requests without a restart, at the cost of being scoped rather than singleton.IOptionsMonitor<T>- a singleton that can still notify you of changes viaOnChange, for long-lived services (a background worker, a singleton) that need to react to a config change mid-execution rather than only at the next scope boundary.
public class EmailSender(IOptionsMonitor<SmtpOptions> options)
{
public void Send()
{
var current = options.CurrentValue; // always the latest value
}
}
Picking IOptions<T> for something injected into a singleton but expected to reflect config file changes is the most common mistake - it silently never updates, because it was only ever resolved once.
Validating configuration at startup, not on first use
ValidateDataAnnotations() plus ValidateOnStart() turns a misconfigured section into an immediate startup failure instead of a confusing exception the first time the feature is actually exercised in production:
builder.Services.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection("Smtp"))
.ValidateDataAnnotations()
.ValidateOnStart();
public class SmtpOptions
{
[Required]
public string Host { get; set; } = string.Empty;
[Range(1, 65535)]
public int Port { get; set; }
}
Without ValidateOnStart(), a missing required value only surfaces the first time something actually tries to use it - which might be hours after deploy, on the first email the app tries to send.
Named options for multiple instances of the same shape
Two SMTP configurations (transactional vs. marketing email) sharing the same SmtpOptions shape use named options rather than two separate classes:
builder.Services.Configure<SmtpOptions>("Transactional", builder.Configuration.GetSection("Smtp:Transactional"));
builder.Services.Configure<SmtpOptions>("Marketing", builder.Configuration.GetSection("Smtp:Marketing"));
var transactional = optionsFactory.Get("Transactional");
Common pitfalls
- Injecting
IConfigurationdirectly into business logic instead of binding to a typed options class - loses compile-time safety and scatters magic strings. - Using
IOptions<T>where config needs to change without a restart. Reach forIOptionsSnapshot<T>orIOptionsMonitor<T>instead. - Skipping
ValidateOnStart()and finding out about a missing required setting only when a feature is first used in production. - Binding the same section to multiple differently-shaped classes, which usually signals the configuration itself needs restructuring.
- Forgetting named options exist and duplicating an options class just to represent two instances of the same shape.
Key Takeaways
- The Options pattern binds a configuration section to a typed class once, replacing scattered magic-string lookups.
IOptions<T>is singleton and static for the app's life;IOptionsSnapshot<T>refreshes per scope;IOptionsMonitor<T>is a singleton that can still react to changes.ValidateDataAnnotations()+ValidateOnStart()catches a misconfigured section at startup instead of on first use.- Named options let multiple configuration instances share one options class shape.
Get the Full Source Code
The complete runnable solution - the typed options classes, startup validation, and the named-options example - is available to Patreon supporters. If you want the pattern ready to apply to your own configuration instead of rebuilding it from the walkthrough above, you can find it on Patreon.