The IOptions pattern is ASP.NET Core’s strongly-typed approach to configuration. Instead of reading raw strings from IConfiguration throughout your code (config["Smtp:Host"]), you define a POCO class with the configuration properties, bind it to a configuration section, and inject it as IOptions<SmtpOptions>. This gives you IntelliSense, compile-time type safety, validation at startup, and testability — you can construct the options object directly in tests without a full configuration pipeline. The IOptions pattern should be used for every configurable component in production ASP.NET Core applications.
Defining and Binding Options
// ── 1. Define a strongly-typed options class ──────────────────────────────
public class SmtpOptions
{
public const string SectionName = "Smtp"; // conventional section name constant
[Required]
public string Host { get; init; } = string.Empty;
[Range(1, 65535)]
public int Port { get; init; } = 587;
[Required]
public string Username { get; init; } = string.Empty;
[Required]
public string Password { get; init; } = string.Empty;
public bool UseSsl { get; init; } = true;
}
// ── appsettings.json ───────────────────────────────────────────────────────
// {
// "Smtp": {
// "Host": "smtp.sendgrid.net",
// "Port": 587,
// "Username": "apikey",
// "Password": "SG.xxx",
// "UseSsl": true
// }
// }
// ── 2. Register in Program.cs ─────────────────────────────────────────────
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.SectionName)
.ValidateDataAnnotations() // validate Required, Range, etc. on startup
.ValidateOnStart(); // fail fast — validate before first request
// Shorthand (without validation):
builder.Services.Configure<SmtpOptions>(
builder.Configuration.GetSection(SmtpOptions.SectionName));
ValidateOnStart() validates the options when the host starts — before any request is processed. Without it, options validation (DataAnnotations and custom validators) only runs on first access. ValidateOnStart() means a misconfigured deployment fails loudly at startup rather than silently at runtime when the feature is first used. This is the fail-fast principle applied to configuration — always enable it for required configuration sections in production applications.public const string SectionName = "Smtp" constant to your options class. Use it in both the binding call (.BindConfiguration(SmtpOptions.SectionName)) and in tests when constructing the options manually. This eliminates magic strings — if the configuration section is renamed, you change it in one place. The convention is also self-documenting: any developer reading the class can immediately see which configuration section it maps to.IConfiguration directly into application services to read configuration values — this is the configuration equivalent of using Service Locator. It bypasses the options validation pipeline, makes the service harder to test (you must configure IConfiguration in tests), and hides the service’s configuration requirements. Use IOptions<T> for all configuration in application services. Reserve IConfiguration for infrastructure bootstrapping in Program.cs.IOptions vs IOptionsSnapshot vs IOptionsMonitor
// ── IOptions<T> — registered as Singleton; value set once at startup ──────
public class EmailService(IOptions<SmtpOptions> options)
{
private readonly SmtpOptions _smtp = options.Value;
// Value is fixed for the application lifetime — changes to appsettings
// require restart. Use for config that should not change at runtime.
public async Task SendAsync(string to, string subject, string body)
=> Console.WriteLine($"Sending via {_smtp.Host}:{_smtp.Port}");
}
// ── IOptionsSnapshot<T> — registered as Scoped; reloaded per request ──────
public class FeatureService(IOptionsSnapshot<FeatureFlags> flags)
{
// flags.Value is re-read from configuration on each HTTP request.
// Changes to appsettings take effect on the NEXT request (no restart).
// Use for feature flags and runtime-tunable configuration.
public bool IsNewUiEnabled => flags.Value.NewUi;
}
// ── IOptionsMonitor<T> — registered as Singleton; notified on change ──────
public class RateLimitService(IOptionsMonitor<RateLimitOptions> monitor) : IDisposable
{
private IDisposable? _subscription;
private RateLimitOptions _current = monitor.CurrentValue;
public void StartMonitoring()
{
// Callback fires when configuration file is changed
_subscription = monitor.OnChange(opts =>
{
_current = opts;
Console.WriteLine($"Rate limit updated: {opts.RequestsPerMinute} req/min");
});
}
// Use for long-running services (Singleton, BackgroundService) that
// need to react to configuration changes without restarting.
public void Dispose() => _subscription?.Dispose();
}
IOptions Variant Reference
| Variant | Lifetime | Reloads? | Best For |
|---|---|---|---|
| IOptions<T> | Singleton | No (restart needed) | Stable config (DB connection, API keys) |
| IOptionsSnapshot<T> | Scoped | Per request | Feature flags, per-request tuneable config |
| IOptionsMonitor<T> | Singleton | On file change | Long-running services needing live reload |
Common Mistakes
Mistake 1 — Not calling ValidateOnStart (silent misconfiguration)
❌ Wrong — missing SMTP host discovered only when the first email is sent, possibly hours after deployment.
✅ Correct — always chain .ValidateDataAnnotations().ValidateOnStart() for required configuration.
Mistake 2 — Injecting IOptionsSnapshot into a Singleton service (lifetime mismatch)
❌ Wrong — IOptionsSnapshot is Scoped; injecting it into a Singleton throws InvalidOperationException.
✅ Correct — use IOptionsMonitor in Singleton services; use IOptionsSnapshot in Scoped services only.