Configuration and the Options Pattern in ASP.NET Core
ASP.NET Core's configuration system is built on a layered model where values from multiple sources are merged together. Combined with the Options pattern, it gives you strongly-typed, validatable settings without scattering magic strings throughout your code.
Configuration Sources
WebApplication.CreateBuilder() sets up several configuration sources by default, in this order (later sources override earlier ones):
appsettings.jsonappsettings.{Environment}.json- User secrets (in Development)
- Environment variables
- Command-line arguments
{
"SmtpSettings": {
"Host": "smtp.example.com",
"Port": 587,
"EnableSsl": true
}
}
You can read values directly using IConfiguration, but this approach relies on string keys and returns untyped values:
app.MapGet("/config", (IConfiguration config) =>
{
var host = config["SmtpSettings:Host"]; // string or null
var port = config.GetValue<int>("SmtpSettings:Port"); // typed but still keyed
return new { host, port };
});
This works for quick checks, but it is fragile. The Options pattern solves this.
The Options Pattern
Define a class that mirrors the shape of your configuration section:
public class SmtpSettings
{
public const string SectionName = "SmtpSettings";
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 587;
public bool EnableSsl { get; set; } = true;
}
Bind it during service registration:
builder.Services.Configure<SmtpSettings>(
builder.Configuration.GetSection(SmtpSettings.SectionName));
Now inject IOptions<SmtpSettings> wherever you need it:
public class EmailService
{
private readonly SmtpSettings _settings;
public EmailService(IOptions<SmtpSettings> options)
{
_settings = options.Value;
}
public void Send(string to, string subject, string body)
{
using var client = new SmtpClient(_settings.Host, _settings.Port);
client.EnableSsl = _settings.EnableSsl;
// send the email...
}
}
IOptions vs IOptionsSnapshot vs IOptionsMonitor
The framework provides three interfaces for consuming options, each with a different lifetime:
| Interface | Lifetime | Reloads on change? |
|---|---|---|
IOptions<T> |
Singleton | No — read once at startup |
IOptionsSnapshot<T> |
Scoped | Yes — per request |
IOptionsMonitor<T> |
Singleton | Yes — via change callbacks |
Use IOptions<T> when settings do not change at runtime. Use IOptionsSnapshot<T> in scoped services (like controllers) when you want updated values on each request. Use IOptionsMonitor<T> in singletons that need to react to configuration changes:
public class CacheService
{
private readonly IOptionsMonitor<CacheSettings> _monitor;
public CacheService(IOptionsMonitor<CacheSettings> monitor)
{
_monitor = monitor;
_monitor.OnChange(settings =>
{
Console.WriteLine($"Cache TTL changed to {settings.TtlMinutes} minutes");
});
}
public TimeSpan GetTtl() =>
TimeSpan.FromMinutes(_monitor.CurrentValue.TtlMinutes);
}
Validating Options
Binding configuration to a class does not guarantee correctness. Add validation using data annotations or custom logic:
builder.Services.AddOptions<SmtpSettings>()
.Bind(builder.Configuration.GetSection(SmtpSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart(); // Fail fast at startup, not on first use
Decorate the settings class with validation attributes:
public class SmtpSettings
{
public const string SectionName = "SmtpSettings";
[Required]
public string Host { get; set; } = string.Empty;
[Range(1, 65535)]
public int Port { get; set; } = 587;
public bool EnableSsl { get; set; } = true;
}
ValidateOnStart() is particularly important — without it, validation only runs when the options are first resolved. If a required setting is missing, you will not discover it until the first request hits the affected code path.
Named Options
When you have multiple instances of the same configuration shape (for example, multiple API clients), use named options:
builder.Services.Configure<ApiClientSettings>("GitHub",
builder.Configuration.GetSection("ApiClients:GitHub"));
builder.Services.Configure<ApiClientSettings>("Slack",
builder.Configuration.GetSection("ApiClients:Slack"));
// Resolve by name
public class ApiClientFactory
{
private readonly IOptionsSnapshot<ApiClientSettings> _options;
public ApiClientFactory(IOptionsSnapshot<ApiClientSettings> options)
{
_options = options;
}
public ApiClientSettings GetGitHub() => _options.Get("GitHub");
public ApiClientSettings GetSlack() => _options.Get("Slack");
}
Key Takeaways
The configuration system and Options pattern together give you a clean, testable way to manage application settings. Use strongly-typed classes rather than string keys, validate settings at startup with ValidateOnStart(), and choose the right IOptions interface based on whether your service needs to react to runtime changes.