Skip to content

.NET Core · Dependency Injection

The .NET Options Pattern: IOptions, IOptionsSnapshot, and IOptionsMonitor

6 min read Updated 2026-06-23 Share:

Practice Options Pattern interview questions

Why the options pattern matters for .NET interviews

Interviewers ask about the options pattern because the wrong approach — injecting raw IConfiguration everywhere, reading settings without validation, or using IOptions<T> in a service that needs live reloads — produces hard-to-debug runtime failures. This article explains every variant and when to reach for each one.

The problem with IConfiguration in application code

Without the options pattern, services reach into raw configuration by string key:

// Fragile: magic strings, no type safety, no null checking, not testable:
public class EmailService
{
    public EmailService(IConfiguration config)
    {
        var host    = config["Smtp:Host"];           // null if key missing
        var port    = int.Parse(config["Smtp:Port"]); // FormatException if malformed
        var apiKey  = config["Smtp:ApiKey"];          // wrong case → silent null
    }
}

The options pattern wraps configuration in a typed, validated, injectable class:

// Settings class — plain C# POCO:
public class SmtpSettings
{
    public string Host   { get; set; } = "";
    public int    Port   { get; set; } = 587;
    public bool   UseTls { get; set; } = true;
    public string ApiKey { get; set; } = "";
}

// Registration:
builder.Services.Configure<SmtpSettings>(
    builder.Configuration.GetSection("Smtp"));

// Injection — strongly typed, testable, no magic strings:
public class EmailService
{
    private readonly SmtpSettings _settings;

    public EmailService(IOptions<SmtpSettings> options)
        => _settings = options.Value;
}

The three options interfaces

This is the question interviewers ask most often about the options pattern.

IOptions<T> — Singleton, frozen at startup

// Registered as Singleton; reads config once at startup; never reloads:
public class PaymentService
{
    public PaymentService(IOptions<StripeSettings> options)
    {
        var settings = options.Value; // same object for entire app lifetime
    }
}

Use IOptions<T> for configuration that never changes after startup (connection strings, API endpoints, feature flags set at deploy time). It can be safely injected into Singleton, Scoped, or Transient services.

IOptionsSnapshot<T> — Scoped, re-reads per request

// New snapshot per HTTP request — reflects file changes between requests:
public class PricingService
{
    public PricingService(IOptionsSnapshot<PricingSettings> options)
    {
        var settings = options.Value; // fresh per request
    }
}

Use IOptionsSnapshot<T> when config can change at runtime and you need each request to see the current values. Cannot be injected into Singleton services — it's Scoped.

IOptionsMonitor<T> — Singleton, with change callbacks

// Singleton; provides CurrentValue and fires OnChange when config is reloaded:
public class FeatureFlagService
{
    private FeatureFlags _current;
    private readonly IDisposable? _registration;

    public FeatureFlagService(IOptionsMonitor<FeatureFlags> monitor)
    {
        _current = monitor.CurrentValue;
        _registration = monitor.OnChange(updated =>
        {
            _current = updated;
            Console.WriteLine($"Feature flags updated at {DateTime.UtcNow:O}");
        });
    }
}

Use IOptionsMonitor<T> for Singleton services that need to react to config changes without restarting the app.

Quick comparison

InterfaceLifetimeReloadsUse when
IOptions<T>SingletonNeverStatic config; inject into any lifetime
IOptionsSnapshot<T>ScopedPer requestPer-request fresh values; not in singletons
IOptionsMonitor<T>SingletonOn change + callbackLive reloads in singletons

Binding from configuration

Three equivalent patterns:

// Pattern 1 — Configure (most common):
builder.Services.Configure<SmtpSettings>(
    builder.Configuration.GetSection("Smtp"));

// Pattern 2 — BindConfiguration (AddOptions builder style):
builder.Services.AddOptions<SmtpSettings>()
    .BindConfiguration("Smtp");

// Pattern 3 — Bind to an instance (inject as a concrete type):
var settings = new SmtpSettings();
builder.Configuration.GetSection("Smtp").Bind(settings);
builder.Services.AddSingleton(settings);

Pattern 2 (AddOptions builder) is preferred when you also need validation — it chains cleanly.

Validation at startup

Options validation catches misconfigured environments immediately. Without ValidateOnStart(), validation only runs when options.Value is first accessed — potentially on the first production request.

// Data Annotations — simplest approach:
public class SmtpSettings
{
    [Required]
    public string Host { get; set; } = "";

    [Range(1, 65535)]
    public int Port { get; set; } = 587;

    [Required, MinLength(20)]
    public string ApiKey { get; set; } = "";
}

builder.Services.AddOptions<SmtpSettings>()
    .BindConfiguration("Smtp")
    .ValidateDataAnnotations()
    .ValidateOnStart(); // throw at startup, not on first request

// Custom cross-property validation via IValidateOptions<T>:
public class SmtpSettingsValidator : IValidateOptions<SmtpSettings>
{
    public ValidateOptionsResult Validate(string? name, SmtpSettings opts)
    {
        if (opts.UseTls && opts.Port == 25)
            return ValidateOptionsResult.Fail(
                "TLS enabled but port 25 is for unencrypted SMTP. Use 587 or 465.");

        return ValidateOptionsResult.Success;
    }
}

builder.Services.AddSingleton<IValidateOptions<SmtpSettings>, SmtpSettingsValidator>();

// Inline delegate validation:
builder.Services.AddOptions<SmtpSettings>()
    .BindConfiguration("Smtp")
    .Validate(s => s.Host.Contains('.'), "Smtp:Host must be a valid hostname")
    .ValidateOnStart();

Always pair validation with ValidateOnStart() — fail fast at startup, not under load.

Configure vs PostConfigure

Configure<T> sets values. PostConfigure<T> runs after all Configure<T> calls and wins:

// App configuration from appsettings.json:
builder.Services.Configure<CacheSettings>(builder.Configuration.GetSection("Cache"));
// MaxSize = 500 from appsettings

// Library code enforces a hard cap regardless of what app configured:
builder.Services.PostConfigure<CacheSettings>(settings =>
{
    if (settings.MaxSize > 10_000)
        settings.MaxSize = 10_000; // enforced regardless of appsettings

    // Ensure derived values stay consistent:
    settings.EvictionBatchSize = Math.Min(
        settings.EvictionBatchSize, settings.MaxSize / 10);
});

// Apply PostConfigure to ALL named instances of a type:
builder.Services.PostConfigureAll<ApiClientSettings>(settings =>
{
    if (!settings.BaseUrl.EndsWith('/'))
        settings.BaseUrl += '/';
});

Rule: Use Configure<T> in application code. Use PostConfigure<T> in library/framework code to enforce invariants that must hold regardless of what the consuming app configured.

Named options

When the same settings shape covers multiple logical configurations:

// appsettings.json:
// {
// "Stripe": { "BaseUrl": "https://api.stripe.com", "ApiKey": "sk_live_..." },
// "Sendgrid": { "BaseUrl": "https://api.sendgrid.com", "ApiKey": "SG.xxx" }
// }

public class ApiClientSettings
{
    public string BaseUrl   { get; set; } = "";
    public string ApiKey    { get; set; } = "";
    public int    TimeoutMs { get; set; } = 5000;
}

builder.Services.Configure<ApiClientSettings>("Stripe",
    builder.Configuration.GetSection("Stripe"));
builder.Services.Configure<ApiClientSettings>("Sendgrid",
    builder.Configuration.GetSection("Sendgrid"));

// Resolve by name:
public class PaymentService
{
    public PaymentService(IOptionsSnapshot<ApiClientSettings> options)
    {
        var stripe   = options.Get("Stripe");   // or IOptionsMonitor.Get("Stripe")
        var sendgrid = options.Get("Sendgrid");
    }
}

// IOptions<T>.Value always returns the unnamed (default) instance:
// options.Value == options.Get(Options.DefaultName) == options.Get("")

Testing options without a config file

No container or appsettings.json needed in unit tests:

// Options.Create wraps an in-memory instance as IOptions<T>:
var sut = new EmailService(Options.Create(new SmtpSettings
{
    Host   = "smtp.test.com",
    Port   = 587,
    ApiKey = "test-key"
}));

// For IOptionsSnapshot<T>, use a mock or TestOptionsManager:
var snapshot = Substitute.For<IOptionsSnapshot<SmtpSettings>>();
snapshot.Value.Returns(new SmtpSettings { Host = "smtp.test.com" });
snapshot.Get("Primary").Returns(new SmtpSettings { Host = "primary.smtp.com" });

Options.Create(new T { ... }) is the fastest path to a testable options injection.

The AddOptions builder — the preferred registration style

builder.Services
    .AddOptions<JwtSettings>()
    .BindConfiguration("Jwt")
    .Validate(s => s.Secret.Length >= 32,
        "Jwt:Secret must be at least 32 characters")
    .Validate(s => Uri.TryCreate(s.Issuer, UriKind.Absolute, out _),
        "Jwt:Issuer must be a valid absolute URI")
    .ValidateDataAnnotations()
    .ValidateOnStart()
    .PostConfigure(s => s.Issuer = s.Issuer.TrimEnd('/').ToLower());

One chain replaces scattered Configure + validator registration + PostConfigure calls and makes the full intent visible in one place.

Recap

The options pattern replaces raw IConfiguration access with strongly typed, injectable, validated settings objects. Use IOptions<T> for static config (any lifetime), IOptionsSnapshot<T> for per-request fresh values (Scoped only), and IOptionsMonitor<T> for reactive live reloads in singletons. Always add ValidateOnStart() so misconfigured settings fail at startup. Use PostConfigure<T> for invariants that must hold regardless of what application code configured. Test with Options.Create(new T { ... }) — no container required.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel