← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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:

IOptions<T>

IOptionsSnapshot<T>

IOptionsMonitor<T>

Quick pick

Why Does It Exist?

The Problem — Injecting IConfiguration Everywhere

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
        // ...
    }
}

The Solution — A Typed, Testable, DI-Native 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.

Big Picture

THE OPTIONS PATTERN, END TO END
Configuration source
Bound to a POCO settings class
Wrapped by IOptions<T> / IOptionsSnapshot<T> / IOptionsMonitor<T>
Injected into any consumer

How It Works

WIRING UP OPTIONS
1. DEFINE A PLAIN SETTINGS CLASS
public class WeatherApiSettings
{
    public string BaseUrl { get; set; } = string.Empty;
    public int RetryCount { get; set; } = 3;
}
2. CONFIGURE IT AGAINST A SECTION
builder.Services.Configure<WeatherApiSettings>(
    builder.Configuration.GetSection("WeatherApi"));
3. INJECT AND READ
public class WeatherApiClient(IOptions<WeatherApiSettings> options)
{
    private readonly WeatherApiSettings _settings = options.Value;
}

Simple Example

// 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.

Real-World Example

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.

Analogy

A Photo vs. a Live Camera Feed

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.

Under the Hood

WHY THE THREE INTERFACES HAVE DIFFERENT LIFETIMES
IOptions<T> — Singleton
IOptionsSnapshot<T> — Scoped
IOptionsMonitor<T> — Singleton, but internally reactive

Common Confusion

"IOptionsSnapshot updates live" — not quite

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.

The Options Pattern doesn't replace IConfiguration — it's built on top of 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.

Common Mistakes

Mistake 1 — Injecting IOptionsSnapshot into a Singleton

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.

Mistake 2 — Forgetting ValidateOnStart()

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.

Mistake 3 — Reaching for IOptionsMonitor everywhere "just in case"

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.

When Should I Use It?

Mental Model

IOptions<T> = a photo, taken once
IOptionsSnapshot<T> = a fresh photo per visit (scope)
IOptionsMonitor<T> = a live camera feed, with alerts

Remember:
· Bind once with Configure<T>, inject the wrapper anywhere.
· Never inject Scoped IOptionsSnapshot into a Singleton — that's a captive dependency.
· Add .ValidateOnStart() so bad configuration fails loudly, at startup.

Key Takeaway


Check Your Understanding

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"]?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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.