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):

  1. appsettings.json
  2. appsettings.{Environment}.json
  3. User secrets (in Development)
  4. Environment variables
  5. Command-line arguments
appsettings.json
{
  "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:

Example.cs
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:

SmtpSettings.cs
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:

Program.cs
builder.Services.Configure<SmtpSettings>(
    builder.Configuration.GetSection(SmtpSettings.SectionName));

Now inject IOptions<SmtpSettings> wherever you need it:

EmailService.cs
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:

CacheService.cs
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:

Program.cs
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:

SmtpSettings.cs
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:

Example.cs
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.