Reading configuration with magic strings scattered through the codebase is fragile and untestable. The Options pattern binds configuration to strongly-typed classes and hands them to code through dependency injection — with validation, live reloads, and named variants. This is the full picture, from the first Configure call to startup validation and runtime change tracking.
This is the first entry in a Design Patterns in C# topic. It covers the Options pattern — the standard .NET way to turn configuration (from appsettings.json, environment variables, and other sources) into strongly-typed objects that get injected wherever they are needed. Every example here is runnable: copy the options class, the JSON, and the registration into an ASP.NET Core app and it works.
The article moves from the simplest possible use up to validation, named options, and runtime reloads, so a beginner can start at the top and an experienced reader can jump to the section they need.
Configuration values — a database connection, an API key, an SMTP host — have to come from somewhere outside the code so they can change between environments. The naive way to read them is straight off IConfiguration by key:
var host = configuration["Email:Host"];
int port = int.Parse(configuration["Email:Port"]); // manual parse; throws on bad input
bool ssl = bool.Parse(configuration["Email:EnableSsl"]);This works, and it is a trap. The keys are magic strings, so a typo like "Email:Hostt" compiles fine and returns null at runtime. The types are all strings until parsed by hand. There is no validation, so a missing or malformed value fails deep inside the app instead of at startup. And any class doing this needs IConfiguration injected, which makes it awkward to unit test. The configuration is scattered, stringly-typed, and unchecked.
The Options pattern replaces all of that with one strongly-typed class. Configuration is bound to that class once, and the class is handed to whatever needs it through dependency injection.
The payoff is the reverse of every problem above: keys become properties checked by the compiler, values arrive already typed, validation can run at startup, and consumers depend on a plain object instead of IConfiguration. Four small steps set it up.
An options class is a plain C# object — properties with getters and setters, no base class, no attributes required. A common convention is a SectionName constant so the configuration section name lives with the class instead of as a loose string:
public class EmailOptions
{
public const string SectionName = "Email";
public string Host { get; set; } = "";
public int Port { get; set; }
public string FromAddress { get; set; } = "";
public bool EnableSsl { get; set; }
public string[] Recipients { get; set; } = [];
}Nothing here is special to the Options pattern yet — it is just a container shaped like the configuration it will hold.
The JSON section mirrors the class. Property names match keys (case-insensitively), and nested objects and arrays map to nested classes and collections:
{
"Email": {
"Host": "smtp.example.com",
"Port": 587,
"FromAddress": "noreply@example.com",
"EnableSsl": true,
"Recipients": [ "ops@example.com", "alerts@example.com" ]
}
}The "Email" object corresponds to EmailOptions, each key to a property, and the "Recipients" array to the string[].
In Program.cs, tell the dependency injection container to bind that section to the class. Configure<T> reads the section and wires up the options machinery:
builder.Services.Configure<EmailOptions>(
builder.Configuration.GetSection(EmailOptions.SectionName));That single line registers EmailOptions so it can be injected anywhere, in three different wrappers described shortly.
A consumer asks for IOptions<EmailOptions> in its constructor and reads the bound object from .Value:
public class EmailSender
{
private readonly EmailOptions _options;
public EmailSender(IOptions<EmailOptions> options)
{
_options = options.Value; // the bound EmailOptions instance
}
public void Send(string subject)
{
Console.WriteLine($"Connecting to {_options.Host}:{_options.Port}, SSL={_options.EnableSsl}");
// real send logic here
}
}_options.Host is a compiler-checked property, already a string; _options.Port is already an int. No keys, no parsing, no IConfiguration.
Put together, here is a minimal ASP.NET Core app that binds the options and exposes them on an endpoint. Drop the EmailOptions class and the "Email" section from above alongside this Program.cs and it runs:
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<EmailOptions>(
builder.Configuration.GetSection(EmailOptions.SectionName));
var app = builder.Build();
app.MapGet("/email-config", (IOptions<EmailOptions> options) =>
{
return Results.Ok(options.Value); // returns the bound EmailOptions as JSON
});
app.Run();Hitting GET /email-config returns the fully typed, bound configuration. Everything after this point refines this base: how the values refresh, how they are validated, and how to have several of them.
This is not only for web apps. The Options pattern lives in
Microsoft.Extensions.Options, part of the generic host. A console app or worker service built withHost.CreateApplicationBuilderregisters and consumes options exactly the same way —Configure<T>andIOptions<T>are not ASP.NET-specific.
There is not one way to consume options but three, and choosing correctly is the single most important part of the pattern. They differ in lifetime, in whether they notice configuration reloads, and in whether they support named options:
| Interface | Lifetime | Value freshness | Named options | Inject into |
|---|---|---|---|---|
IOptions<T> | singleton | computed once, cached forever | no | anything |
IOptionsSnapshot<T> | scoped | recomputed once per request | yes | scoped services, controllers |
IOptionsMonitor<T> | singleton | always current, plus change notifications | yes | anything, including singletons |
IOptions<T> is the simplest. The bound object is built the first time it is requested and cached for the life of the application. It never sees a configuration change. Use it for settings that do not change while the app runs, which is most of them.
IOptionsSnapshot<T> is scoped — a fresh value is computed once per request (per DI scope). If appsettings.json changed since the last request, the new request sees the new values. It also supports named options via .Get(name). Because it is scoped, it cannot be injected into a singleton.
public class ReportService
{
private readonly EmailOptions _options;
public ReportService(IOptionsSnapshot<EmailOptions> snapshot)
{
_options = snapshot.Value; // re-read for this request
}
}IOptionsMonitor<T> is a singleton that always exposes the current value through .CurrentValue, and can push notifications when the configuration changes through .OnChange(...). It is the option to use inside singletons and long-running background services, where a scoped snapshot is unavailable but fresh values are still wanted.
public class QueueWorker
{
private readonly IOptionsMonitor<EmailOptions> _monitor;
public QueueWorker(IOptionsMonitor<EmailOptions> monitor)
{
_monitor = monitor;
_monitor.OnChange(updated =>
Console.WriteLine($"Email config changed, new host: {updated.Host}"));
}
public void Process()
{
var current = _monitor.CurrentValue; // never stale
}
}The rule that prevents the most common bug: never inject
IOptionsSnapshot<T>into a singleton — it is scoped, and the container will throw at resolution time. In a singleton that needs configuration, useIOptions<T>for static values orIOptionsMonitor<T>for values that can change. In a controller or scoped service that should pick up reloads per request, useIOptionsSnapshot<T>.
Binding matches configuration keys to properties by name, case-insensitively, and handles more than flat scalars:
"Retry" object inside "Email" binds to a nested RetryOptions property on EmailOptions.T[], List<T>, and similar. The "Recipients" array above fills the string[].Dictionary<string, T>."Level": "Warning" binds to an enum value Warning.A nested example, showing the shape:
public class EmailOptions
{
public const string SectionName = "Email";
public string Host { get; set; } = "";
public int Port { get; set; }
public RetryOptions Retry { get; set; } = new(); // nested object
}
public class RetryOptions
{
public int MaxAttempts { get; set; }
public int DelaySeconds { get; set; }
}{
"Email": {
"Host": "smtp.example.com",
"Port": 587,
"Retry": { "MaxAttempts": 3, "DelaySeconds": 5 }
}
}For a one-off bind without the DI machinery, IConfiguration can bind directly — configuration.GetSection("Email").Get<EmailOptions>() returns a bound object immediately. It is handy in startup code, but it skips the reload, validation, and injection benefits of the full pattern, so it is the exception rather than the rule.
The biggest practical win of the pattern is validating configuration at startup, so a bad value stops the app immediately with a clear message instead of surfacing as a mysterious failure later. Validation is added when registering with AddOptions<T>():
using System.ComponentModel.DataAnnotations;
public class EmailOptions
{
public const string SectionName = "Email";
[Required]
public string Host { get; set; } = "";
[Range(1, 65535)]
public int Port { get; set; }
[Required, EmailAddress]
public string FromAddress { get; set; } = "";
}builder.Services.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
.ValidateDataAnnotations() // enforce the attributes above
.Validate(o => o.Port != 25, "Port 25 is blocked") // a custom rule
.ValidateOnStart(); // fail at startup, not first useThree layers are available:
| Method | What it checks |
|---|---|
ValidateDataAnnotations() | attributes on the class such as [Required] and [Range] |
Validate(predicate, message) | a custom lambda condition with a failure message |
IValidateOptions<T> | complex or reusable validation, registered as its own service |
ValidateOnStart() | runs all of the above at startup rather than on first access |
ValidateOnStart() is the key habit. Without it, validation runs lazily the first time the options are resolved — which might be deep into a request. With it, misconfiguration crashes the app on launch, which is exactly when it should.
For rules too involved for a lambda, implement IValidateOptions<T>:
public class EmailOptionsValidator : IValidateOptions<EmailOptions>
{
public ValidateOptionsResult Validate(string? name, EmailOptions options)
{
if (options.EnableSsl && options.Port == 25)
return ValidateOptionsResult.Fail("SSL cannot be used on port 25.");
return ValidateOptionsResult.Success;
}
}
// register it:
builder.Services.AddSingleton<IValidateOptions<EmailOptions>, EmailOptionsValidator>();Sometimes several configurations share a shape — a primary and a backup mail server, or one set of settings per tenant. Named options register multiple instances of the same type under different names:
builder.Services.Configure<EmailOptions>(
"Primary", builder.Configuration.GetSection("Email:Primary"));
builder.Services.Configure<EmailOptions>(
"Backup", builder.Configuration.GetSection("Email:Backup"));They are retrieved by name through IOptionsSnapshot<T> or IOptionsMonitor<T> with .Get(name):
public class Mailer
{
private readonly EmailOptions _primary;
private readonly EmailOptions _backup;
public Mailer(IOptionsSnapshot<EmailOptions> options)
{
_primary = options.Get("Primary");
_backup = options.Get("Backup");
}
}Plain IOptions<T> only ever exposes the default (unnamed) options, so named options require the snapshot or monitor interface.
Options do not have to come from a file. Configure<T> also accepts a lambda that sets values directly — useful for defaults, tests, or values computed at startup:
builder.Services.Configure<EmailOptions>(o =>
{
o.Host = "localhost";
o.Port = 25;
});When both a file binding and a code lambda are registered, they run in order, so later ones override earlier ones. PostConfigure<T> runs after all normal Configure calls, which is the right place to fill in derived defaults or fix up values once everything else has been applied:
builder.Services.PostConfigure<EmailOptions>(o =>
{
if (string.IsNullOrWhiteSpace(o.FromAddress))
o.FromAddress = $"noreply@{o.Host}";
});By default, the ASP.NET Core host watches appsettings.json and reloads it when the file changes (reloadOnChange is on). The consuming interface decides whether that reload is seen: IOptions<T> never notices, IOptionsSnapshot<T> picks it up on the next request, and IOptionsMonitor<T> reflects it immediately and can run a callback:
_monitor.OnChange(updated =>
{
Console.WriteLine($"Config reloaded: host is now {updated.Host}");
// re-establish connections, clear caches, etc.
});This makes runtime configuration changes possible without a restart — turn a feature flag, change a timeout — for the parts of the app built to observe them.
Because an options class is a plain object and consumers depend on the interface, testing is trivial. Options.Create wraps a hand-built instance in an IOptions<T>, with no configuration system involved:
var options = Options.Create(new EmailOptions
{
Host = "test-host",
Port = 587,
FromAddress = "test@example.com"
});
var sender = new EmailSender(options); // inject the fake options directly
sender.Send("hello");This is the concrete reward for depending on IOptions<EmailOptions> instead of IConfiguration: a test constructs exactly the settings it wants and passes them straight in.
public const string SectionName) so the string lives in one place.ValidateDataAnnotations().ValidateOnStart() turns misconfiguration into an immediate, readable startup failure.IOptions<T> for static values anywhere; IOptionsSnapshot<T> for per-request freshness in scoped code; IOptionsMonitor<T> for singletons and background services.IOptionsSnapshot<T> into a singleton — it is scoped and will throw..Value in a singleton if the config can change — read from IOptionsMonitor<T>.CurrentValue each time instead, or the singleton will hold a stale copy forever.configuration.Get<T>() except in one-off startup code, so validation, reloads, and DI all keep working.Configure<T>, and inject it via IOptions<T>.IOptions (cached singleton), IOptionsSnapshot (per-request, named), IOptionsMonitor (always-current, notifies) — differ by lifetime and reload behaviour, and choosing right avoids the pattern's main pitfalls.IValidateOptions<T>, combined with ValidateOnStart, catches bad config at launch.Options.Create makes the whole thing trivial to test.This is the first article in the Design Patterns in C# topic. Later entries will cover other patterns — the classic Gang of Four set (factory, strategy, decorator, observer) and .NET-native ones like the mediator and repository patterns — each in this same build-it-up, runnable style.