← Back to Blog

The Options Pattern in C#

A practical guide to strongly typed configuration in .NET, including validation, lifetimes, and when to use IOptions, IOptionsSnapshot, and IOptionsMonitor.

What The Options Pattern Solves

In many projects, configuration starts as string lookups scattered across services. That approach is fragile and hard to maintain. The options pattern maps configuration into typed classes so configuration becomes discoverable, testable, and safer to refactor.

Instead of reading values with Configuration["Some:Path"] everywhere, you define a class once and inject it where needed.

Step 1: Define a Strongly Typed Options Class

public sealed class GitHubOptions
{
    public const string SectionName = "GitHub";

    public string BaseUrl { get; init; } = "https://api.github.com";
    public string Repository { get; init; } = string.Empty;
    public int TimeoutSeconds { get; init; } = 30;
}

Step 2: Add Configuration In appsettings.json

{
  "GitHub": {
    "BaseUrl": "https://api.github.com",
    "Repository": "dotnet/runtime",
    "TimeoutSeconds": 10
  }
}

Step 3: Bind and Validate in Program.cs

Bind once at startup. Add validation rules so invalid configuration fails fast before requests are served.

using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddOptions<GitHubOptions>()
    .Bind(builder.Configuration.GetSection(GitHubOptions.SectionName))
    .Validate(o => Uri.IsWellFormedUriString(o.BaseUrl, UriKind.Absolute), "BaseUrl must be a valid absolute URI")
    .Validate(o => !string.IsNullOrWhiteSpace(o.Repository), "Repository is required")
    .Validate(o => o.TimeoutSeconds > 0 && o.TimeoutSeconds <= 120, "TimeoutSeconds must be between 1 and 120")
    .ValidateOnStart();

builder.Services.AddHttpClient<GitHubClient>((sp, client) =>
{
    var options = sp.GetRequiredService<IOptions<GitHubOptions>>().Value;
    client.BaseAddress = new Uri(options.BaseUrl);
    client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
});

Choosing The Right Interface

  • IOptions<T>: singleton-style access to options, generally fixed for app lifetime
  • IOptionsSnapshot<T>: scoped value, recomputed per request, useful in request pipelines
  • IOptionsMonitor<T>: singleton access with change notifications and current value updates

A common rule of thumb is: prefer IOptions<T> for stable config, use IOptionsSnapshot<T> for request-scoped recalculation, and use IOptionsMonitor<T> when you need live updates.

Example Service Using IOptionsSnapshot

public sealed class ReleaseNotesService
{
    private readonly GitHubOptions _options;

    public ReleaseNotesService(IOptionsSnapshot<GitHubOptions> options)
    {
        _options = options.Value;
    }

    public string BuildReleasesPath()
        => $"/repos/{_options.Repository}/releases";
}

Example Worker Using IOptionsMonitor

Background services are typically singletons, so IOptionsMonitor<T> is the safest fit when configuration may change.

public sealed class SyncWorker : BackgroundService
{
    private readonly IOptionsMonitor<GitHubOptions> _optionsMonitor;
    private readonly ILogger<SyncWorker> _logger;

    public SyncWorker(IOptionsMonitor<GitHubOptions> optionsMonitor, ILogger<SyncWorker> logger)
    {
        _optionsMonitor = optionsMonitor;
        _logger = logger;

        _optionsMonitor.OnChange(options =>
        {
            _logger.LogInformation("GitHub options changed. New timeout: {Timeout}", options.TimeoutSeconds);
        });
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var options = _optionsMonitor.CurrentValue;
            _logger.LogInformation("Polling {Repository}", options.Repository);
            await Task.Delay(TimeSpan.FromSeconds(options.TimeoutSeconds), stoppingToken);
        }
    }
}

Named Options

Named options help when you need multiple configurations for the same type, such as calling multiple third-party APIs with different credentials or base URLs.

builder.Services.Configure<GitHubOptions>("PublicApi",
    builder.Configuration.GetSection("GitHub:PublicApi"));

builder.Services.Configure<GitHubOptions>("EnterpriseApi",
    builder.Configuration.GetSection("GitHub:EnterpriseApi"));

public sealed class MultiEndpointClient
{
    private readonly IOptionsMonitor<GitHubOptions> _monitor;

    public MultiEndpointClient(IOptionsMonitor<GitHubOptions> monitor)
    {
        _monitor = monitor;
    }

    public GitHubOptions GetEnterprise() => _monitor.Get("EnterpriseApi");
}

Common Pitfalls

  • Injecting IOptionsSnapshot<T> into singleton services, which causes lifetime mismatch
  • Skipping validation and discovering bad config only after runtime failures
  • Using magic strings throughout code instead of a single options class and section constant
  • Putting secrets directly in source-controlled appsettings files instead of secure providers

Conclusion

The options pattern is one of the simplest upgrades you can make in a .NET codebase. It gives you a clean boundary around configuration, safer refactoring, and better startup feedback when settings are invalid.

References