IConfiguration is stringly-typed and everywhere. The Options Pattern is typed, validated, and injectable.
You could inject IConfiguration into every class that needs a setting, then call configuration["Api:RetryCount"] wherever you need it. It would work. It would also mean every one of those classes now depends on the entire configuration tree, has to know the exact string key path to find its setting, has zero compile-time guarantee the value even exists, and can't easily be unit-tested without constructing a real IConfiguration object.
The Options Pattern exists precisely to fix this. Instead of classes reaching into a big bag of configuration strings, they declare "I need an ApiSettings object" — strongly typed, validated, and supplied through DI exactly like any other service.
In this lesson, you'll learn why the Options Pattern exists, the three flavors — IOptions<T>, IOptionsSnapshot<T>, IOptionsMonitor<T> — and how to wire up validation.
The Options Pattern is a convention for exposing a strongly-typed settings class through DI, instead of every consumer reading raw configuration strings directly. You configure it once, near your app's startup, and then any class can simply ask for IOptions<ApiSettings> in its constructor and get a ready-made, populated ApiSettings object.
Microsoft.Extensions.Options provides IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T> — three DI-resolvable wrappers around a bound configuration class, each with a different reload behavior and lifetime:
.Value.Value.OnChange(...) to react to updates immediately.CurrentValueIOptionsSnapshot<T>IOptionsMonitor<T>IOptions<T>public class WeatherApiClient(IConfiguration configuration)
{
public async Task<Weather> GetWeatherAsync(string city)
{
var baseUrl = configuration["WeatherApi:BaseUrl"]; // string key, no compile check
var retries = int.Parse(configuration["WeatherApi:RetryCount"] ?? "3"); // manual parsing
// ...
}
}
"WeatherApi:BaseUr") fails silently at runtime, not at compile time.WeatherApiClient now requires constructing a real, populated IConfiguration object just to satisfy the constructor — awkward compared to passing a plain settings object.The Options Pattern binds configuration into a plain settings class exactly once, then hands consumers a typed, DI-injectable wrapper around it. Consumers depend only on the tiny slice of configuration they actually need, get real compile-time property access, and can be unit-tested by simply constructing an Options.Create(new WeatherApiSettings { ... }) — no configuration system required at all in the test.
services.Configure<WeatherApiSettings>(config.GetSection("WeatherApi"))Configure<T>public class WeatherApiSettings
{
public string BaseUrl { get; set; } = string.Empty;
public int RetryCount { get; set; } = 3;
}
builder.Services.Configure<WeatherApiSettings>(
builder.Configuration.GetSection("WeatherApi"));
IOptions/IOptionsSnapshot/IOptionsMonitor wrappers for WeatherApiSettings automatically.public class WeatherApiClient(IOptions<WeatherApiSettings> options)
{
private readonly WeatherApiSettings _settings = options.Value;
}
// appsettings.json
// { "WeatherApi": { "BaseUrl": "https://weather.example.com", "RetryCount": 3 } }
public class WeatherApiSettings
{
public string BaseUrl { get; set; } = string.Empty;
public int RetryCount { get; set; }
}
var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<WeatherApiSettings>(builder.Configuration.GetSection("WeatherApi"));
builder.Services.AddTransient<WeatherApiClient>();
using var host = builder.Build();
var client = host.Services.GetRequiredService<WeatherApiClient>();
public class WeatherApiClient(IOptions<WeatherApiSettings> options)
{
public void PrintConfig()
{
var settings = options.Value; // resolved once, cached for this instance's lifetime
Console.WriteLine($"{settings.BaseUrl}, retries: {settings.RetryCount}");
}
}
options.Value materializes the bound WeatherApiSettings — with IOptions<T>, that value is computed once and cached, so it won't reflect any configuration changes made after the app started.
A background worker that polls a pricing API wants to react immediately if an ops engineer updates RetryCount in a reloadable configuration source, without restarting the process. That calls for IOptionsMonitor<T>, plus validation to reject bad values before they're ever used:
using System.ComponentModel.DataAnnotations;
public class PricingApiSettings
{
[Required]
public string BaseUrl { get; set; } = string.Empty;
[Range(1, 10)]
public int RetryCount { get; set; } = 3;
}
builder.Services
.AddOptions<PricingApiSettings>()
.Bind(builder.Configuration.GetSection("PricingApi"))
.ValidateDataAnnotations() // enforces [Required], [Range], etc.
.ValidateOnStart(); // fails fast at startup, not on first use
public class PricingMonitorWorker(
IOptionsMonitor<PricingApiSettings> optionsMonitor,
ILogger<PricingMonitorWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
optionsMonitor.OnChange(settings =>
logger.LogInformation("Pricing API settings changed: RetryCount is now {RetryCount}", settings.RetryCount));
while (!stoppingToken.IsCancellationRequested)
{
var current = optionsMonitor.CurrentValue; // always the latest bound value
// ... use current.BaseUrl, current.RetryCount for this poll ...
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
ValidateOnStart() is important here: without it, a broken configuration value (like RetryCount set to 50, violating the [Range(1,10)]) would only be caught the first time the options object is actually accessed — ValidateOnStart() forces validation to run during application startup instead, so a bad deployment fails immediately and loudly, rather than hours later mid-run.
IOptions<T> is like a photograph taken at the moment the app started — clear, simple, but frozen. If the scene changes later, the photo doesn't.
IOptionsSnapshot<T> is like taking a fresh photo at the start of each new "visit" (each scope/request) — recent, but still frozen for the duration of that one visit.
IOptionsMonitor<T> is a live camera feed — check it at any moment and you see exactly what's happening right now, and you can even subscribe to be notified the instant something changes.
reloadOnChange: true) and re-binds a fresh value whenever a change fires — while remaining safe to hold as a Singleton because it, the wrapper, doesn't change identity, only the value it reports does.A beginner might assume IOptionsSnapshot<T> always has the freshest value. It doesn't — it's computed once per scope, at the moment it's first resolved within that scope, and then stays fixed for the rest of that scope even if configuration changes mid-scope. Only IOptionsMonitor<T>.CurrentValue is guaranteed to be the absolute latest value at the exact moment you read it.
IConfiguration is still the underlying source of truth; the Options Pattern is a typed, DI-friendly layer over it. You still might reach for IConfiguration directly for very dynamic, ad-hoc lookups — but for any well-defined group of related settings, Options is the standard approach.
public class CacheWarmer(IOptionsSnapshot<CacheSettings> options) registered as a Singleton — this is a captive dependency; IOptionsSnapshot<T> is Scoped.
Use IOptionsMonitor<T> in Singletons and long-lived background services instead — it's specifically designed to be safely held long-term.
Adding .ValidateDataAnnotations() without .ValidateOnStart() means a misconfigured deployment won't fail until the very first time something actually resolves and reads the options value — potentially long after startup, deep into production traffic.
Always chain .ValidateOnStart() for settings that are critical to the app functioning correctly, so bad configuration is caught immediately.
Using IOptionsMonitor<T> by default for every settings class, even ones that genuinely never change at runtime, adds unnecessary complexity (subscribing to change callbacks, reasoning about live updates) for no benefit.
Default to plain IOptions<T> unless you specifically need live reload behavior — it's the simplest option and matches most settings' actual needs.
IOptions<T> by default; upgrade to IOptionsSnapshot<T> or IOptionsMonitor<T> only when you genuinely need per-scope freshness or live reload.IConfiguration access may be simpler and is perfectly acceptable.Configure<T>, inject the wrapper anywhere..ValidateOnStart() so bad configuration fails loudly, at startup.
IConfiguration lookups into strongly-typed, DI-injectable settings objects.IOptions<T> (Singleton, fixed at startup), IOptionsSnapshot<T> (Scoped, per-scope refresh), IOptionsMonitor<T> (Singleton, live/reactive).services.Configure<T>(section) binds and registers all three wrappers at once; AddOptions<T>().Bind(...).ValidateDataAnnotations().ValidateOnStart() adds fail-fast validation.IOptionsSnapshot<T> into a Singleton — use IOptionsMonitor<T> for long-lived services instead.Options.Create(new T { ... }) directly in a unit test, no configuration system required.You've seen why the Options Pattern exists and how the three flavors differ. Let's test whether you can pick the right one.
1. What is the main advantage of IOptions<T> over injecting IConfiguration directly and calling configuration["Section:Key"]?
Correct: B
Why B is correct: That's the entire motivation for the Options Pattern — replace stringly-typed configuration lookups scattered across the codebase with a single, typed, testable, DI-injectable settings object.
Why A is incorrect: Performance isn't the driver here; binding still reads from the same underlying configuration.
Why C is incorrect: Options provides no encryption — that's a separate concern (secret stores, etc.).
Why D is incorrect: You still need a configuration source to bind from; Options is a layer on top of, not a replacement for, configuration.
Reinforcement: Options = typed, testable access to a slice of configuration.
2. A long-lived Singleton background worker needs settings that should reflect live updates without restarting the app. Which interface should it inject?
Correct: C
Why C is correct: IOptionsMonitor<T> is registered as a Singleton (safe to hold long-term) and always exposes the current, live value via .CurrentValue, plus a change notification callback — exactly what a long-lived worker needs.
Why A is incorrect: IOptions<T> is fixed at startup and will never reflect later configuration changes.
Why B is incorrect: IOptionsSnapshot<T> is Scoped — injecting it into a Singleton is a captive dependency, and it wouldn't reflect changes mid-scope anyway.
Why D is incorrect: Possible in theory, but it throws away all the benefits of typed, validated Options for no good reason here.
Reinforcement: Singleton + needs live updates → IOptionsMonitor<T>.
3. Why is injecting IOptionsSnapshot<T> into a Singleton service a mistake?
Correct: B
Why B is correct: This is the captive dependency problem from lesson 125 applied specifically to Options: a Scoped service (IOptionsSnapshot) gets permanently captured by a Singleton, freezing its value forever at whatever it was when the Singleton was first built.
Why A is incorrect: IOptionsSnapshot<T> does expose .Value, just like IOptions<T>.
Why C is incorrect: This is a runtime lifetime problem, not something the compiler can catch (though the container's scope validation may catch it at startup in some configurations).
Why D is incorrect: It's explicitly discouraged for exactly the captive dependency reason described in B.
Reinforcement: Match the Options flavor's lifetime to the consumer's own lifetime — this is the same rule from lesson 125, applied to Options specifically.
4. What does adding .ValidateOnStart() to an options configuration chain accomplish?
Correct: B
Why B is correct: Without .ValidateOnStart(), validation only runs lazily, the first time the options value is actually resolved — which could be well after the app has started serving real work. .ValidateOnStart() forces that check to happen immediately during startup, causing a fast, loud failure instead of a delayed, confusing one.
Why A is incorrect: That describes the default lazy-validation behavior — the whole point of .ValidateOnStart() is to change that default.
Why C is incorrect: Validation only checks and reports; it never silently substitutes defaults for invalid values.
Why D is incorrect: It's the opposite — it adds an extra validation step at startup, not removes one.
Reinforcement: Fail fast at startup beats failing mysteriously in production hours later.
You now understand why the Options Pattern exists, and can choose confidently between IOptions, IOptionsSnapshot, and IOptionsMonitor.
dotnetmadeeasy.com — Learn C# and .NET, the right way.