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
| Interface | Lifetime | Reloads | Use when |
|---|---|---|---|
IOptions<T> | Singleton | Never | Static config; inject into any lifetime |
IOptionsSnapshot<T> | Scoped | Per request | Per-request fresh values; not in singletons |
IOptionsMonitor<T> | Singleton | On change + callback | Live 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.