IOptions, IOptionsSnapshot, and IOptionsMonitor: Choosing the Right One
The Options pattern in .NET provides a clean way to bind configuration sections to strongly-typed classes. But the framework offers three interfaces for consuming those options — IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T> — and choosing the wrong one leads to stale configuration, unexpected reloads, or subtle memory leaks.
Setting up the Options pattern
First, define a configuration class:
public class SmtpSettings
{
public const string SectionName = "Smtp";
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool UseSsl { get; set; } = true;
}
Register it with the DI container:
builder.Services.Configure<SmtpSettings>(
builder.Configuration.GetSection(SmtpSettings.SectionName));
And the corresponding appsettings.json:
{
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"Username": "[email protected]",
"Password": "secret",
"UseSsl": true
}
}
IOptions<T>: singleton, read once
IOptions<T> is registered as a singleton. It reads the configuration values once — at the time of first resolution — and caches them for the lifetime of the application.
public class EmailService
{
private readonly SmtpSettings _settings;
public EmailService(IOptions<SmtpSettings> options)
{
_settings = options.Value;
}
}
When to use: Background services, startup configuration, and any scenario where the configuration never changes at runtime. It's the simplest and most performant option.
Limitation: If you modify appsettings.json while the application is running, IOptions<T> will not pick up the changes. The cached value persists until the process restarts.
IOptionsSnapshot<T>: scoped, per-request
IOptionsSnapshot<T> is registered as scoped. In an ASP.NET Core application, this means it re-reads the configuration at the start of each HTTP request and caches it for the duration of that request.
public class EmailService
{
private readonly SmtpSettings _settings;
public EmailService(IOptionsSnapshot<SmtpSettings> options)
{
_settings = options.Value;
}
}
When to use: ASP.NET Core controllers and services where you want configuration changes to take effect without restarting the application, but you need consistency within a single request.
Limitation: It cannot be injected into singleton services. Attempting to do so throws an exception because a scoped service cannot be consumed by a singleton. It also has slightly higher overhead than IOptions<T> since it re-evaluates the configuration on each scope creation.
IOptionsMonitor<T>: singleton, live updates
IOptionsMonitor<T> is registered as a singleton but provides the CurrentValue property, which always returns the latest configuration. It also exposes an OnChange callback for reacting to updates.
public class EmailService : IDisposable
{
private SmtpSettings _settings;
private readonly IDisposable? _changeSubscription;
public EmailService(IOptionsMonitor<SmtpSettings> monitor)
{
_settings = monitor.CurrentValue;
_changeSubscription = monitor.OnChange(updated =>
{
_settings = updated;
Console.WriteLine($"SMTP host changed to {updated.Host}");
});
}
public void Dispose()
{
_changeSubscription?.Dispose();
}
}
When to use: Singleton services that need to react to configuration changes — feature flags, connection strings, rate limits. It's the right choice for background services and hosted services that run for the entire application lifetime.
Limitation: You need to handle thread safety yourself. If multiple threads read _settings while OnChange is updating it, you should use Volatile.Read or other synchronisation mechanisms.
Quick comparison
| Interface | Lifetime | Reloads on change | Injectable into singletons |
|---|---|---|---|
IOptions<T> |
Singleton | No | Yes |
IOptionsSnapshot<T> |
Scoped | Per-request | No |
IOptionsMonitor<T> |
Singleton | Live | Yes |
Validation
All three interfaces support validation. Register validators to catch configuration errors early:
builder.Services.AddOptions<SmtpSettings>()
.Bind(builder.Configuration.GetSection(SmtpSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
Add data annotations to the class:
public class SmtpSettings
{
[Required]
public string Host { get; set; } = "";
[Range(1, 65535)]
public int Port { get; set; } = 587;
}
ValidateOnStart() ensures validation runs during application startup rather than on first access, so misconfiguration fails fast.
Named options
All three interfaces support named options for scenarios where you have multiple instances of the same configuration type:
builder.Services.Configure<SmtpSettings>("Transactional",
builder.Configuration.GetSection("Smtp:Transactional"));
builder.Services.Configure<SmtpSettings>("Marketing",
builder.Configuration.GetSection("Smtp:Marketing"));
Access them by name:
public class EmailService
{
public EmailService(IOptionsSnapshot<SmtpSettings> options)
{
var transactional = options.Get("Transactional");
var marketing = options.Get("Marketing");
}
}
Wrapping up
The decision tree is straightforward: use IOptions<T> for static configuration in singletons, IOptionsSnapshot<T> for per-request configuration in scoped services, and IOptionsMonitor<T> for live-updating configuration in singletons. When in doubt, IOptionsMonitor<T> is the most flexible — but reach for IOptions<T> first if you don't need runtime reloading.