Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 005 / 100

The Options pattern, properly: best practices explained

Stop reading IConfiguration strings all over your codebase.

Expona Admin7 min read

The Options pattern, properly: best practices explained. Stop reading IConfiguration strings all over your codebase.

Introduction

We've all seen it: configuration strings scattered across the codebase, magic keys hardcoded in multiple services, and no validation until runtime. The Options pattern, properly implemented, eliminates this chaos by binding configuration sections to typed classes once and reusing them consistently. This day covers the three pillars of the Options pattern in ASP.NET Core—IOptions, IOptionsSnapshot, and IOptionsMonitor—so your team can stop reading raw IConfiguration and start shipping maintainable, validated configuration.

The problem with raw IConfiguration

Before we discuss the solution, let's acknowledge why this matters. Consider an SMTP service that appears in multiple places:

csharp
// Service 1
var host = configuration["Smtp:Host"];
var port = int.Parse(configuration["Smtp:Port"]);

// Service 2
var host = configuration.GetSection("Smtp")["Host"];
var port = int.Parse(configuration.GetSection("Smtp")["Port"]);

// Service 3
var password = configuration["Smtp:Password"];
// ... but is it required? What if it's null?

This approach creates several problems: string keys become points of failure (typos go unnoticed until runtime), casting and parsing logic repeats everywhere, validation is inconsistent or missing, and refactoring configuration keys breaks the entire application silently. The Options pattern solves all of these by moving configuration into strongly-typed classes validated once at startup.

Bind a section to a typed class once

The first step is creating a configuration class that represents a single logical section of your appsettings.json. This class becomes the single source of truth for that configuration block.

Define your configuration class with clear property names and data annotations for validation:

csharp
public class SmtpOptions
{
    public string Host { get; set; }
    public int Port { get; set; }
    public string Username { get; set; }
    [Required]
    public string Password { get; set; }
    public bool EnableSsl { get; set; } = true;
    [Range(1, 300)]
    public int TimeoutSeconds { get; set; } = 30;
}

In your appsettings.json, structure the section to match the class property names (case-insensitive binding is the default):

csharp
{
  "Smtp": {
    "Host": "smtp.gmail.com",
    "Port": 587,
    "Username": "noreply@example.com",
    "Password": "secret-password",
    "EnableSsl": true,
    "TimeoutSeconds": 30
  }
}

Now register the options in your service configuration, binding the "Smtp" section and validating immediately:

csharp
builder.Services
    .AddOptions()
    .BindConfiguration("Smtp")
    .ValidateDataAnnotations()
    .ValidateOnStart();

The BindConfiguration method matches the section name to the class property names. ValidateDataAnnotations checks attributes like [Required] and [Range]. ValidateOnStart ensures validation runs during application startup, catching configuration errors before handling requests—critical for production stability.

IOptions: singleton, read once at startup

Use IOptions<T> when your configuration never changes after the application starts. This is the most common case and the most efficient.

Inject IOptions<SmtpOptions> into any service that needs SMTP settings:

csharp
public class EmailService
{
    private readonly SmtpOptions _smtpOptions;

    public EmailService(IOptions options)
    {
        _smtpOptions = options.Value;
    }

    public async Task SendEmailAsync(string to, string subject, string body)
    {
        using (var client = new SmtpClient(_smtpOptions.Host, _smtpOptions.Port))
        {
            client.EnableSsl = _smtpOptions.EnableSsl;
            client.Credentials = new NetworkCredential(
                _smtpOptions.Username,
                _smtpOptions.Password);
            client.Timeout = _smtpOptions.TimeoutSeconds * 1000;

            var message = new MailMessage("noreply@example.com", to)
            {
                Subject = subject,
                Body = body
            };

            await client.SendMailAsync(message);
        }
    }
}

IOptions<T> is registered as a singleton by default, meaning the configuration is read and validated once at startup and cached for the application lifetime. Access the bound values via the .Value property. This approach is lightweight, predictable, and ideal for static configuration like API keys, connection strings, or feature flags that don't change.

IOptionsSnapshot: re-read per request (scoped)

IOptionsSnapshot<T> re-evaluates the configuration for each request (scoped lifetime). Use this when configuration might change during the application lifetime but you want consistency within a single request.

Register the snapshot version alongside IOptions:

csharp
builder.Services
    .AddOptions()
    .BindConfiguration("Smtp")
    .ValidateDataAnnotations()
    .ValidateOnStart();

// IOptionsSnapshot is automatically available
// and re-reads configuration each request.

Inject and use it exactly like IOptions:

csharp
public class NotificationController : ControllerBase
{
    private readonly IOptionsSnapshot _smtpSnapshot;

    public NotificationController(IOptionsSnapshot smtpSnapshot)
    {
        _smtpSnapshot = smtpSnapshot;
    }

    [HttpPost("send-alert")]
    public async Task SendAlert()
    {
        var currentSmtpConfig = _smtpSnapshot.Value;
        // Use currentSmtpConfig for this request only.
        // If configuration changes mid-request in other threads,
        // this request still uses the version from request start.
        return Ok();
    }
}

Common scenarios for IOptionsSnapshot include feature flags that change based on database values, tenant-specific configuration in multi-tenant systems, or settings updated through an admin panel. The scoped lifetime ensures each request sees a consistent view of configuration for the duration of that request.

IOptionsMonitor: live reload with change callbacks

IOptionsMonitor<T> continuously monitors configuration and supports change callbacks. It's singleton-scoped like IOptions, but always reflects the latest configuration without restarting the application.

Register the same way—IOptionsMonitor<T> is automatically available:

csharp
public class FeatureFlagService
{
    private readonly IOptionsMonitor _monitor;

    public FeatureFlagService(IOptionsMonitor monitor)
    {
        _monitor = monitor;

        // Listen for changes
        monitor.OnChange((newOptions, name) =>
        {
            Console.WriteLine($"Feature flags updated: {name}");
            // Reload cache, restart timers, etc.
        });
    }

    public bool IsFeatureEnabled(string featureName)
    {
        // Always reads the latest value
        return _monitor.CurrentValue.Features
            .TryGetValue(featureName, out var enabled) && enabled;
    }
}

Use IOptionsMonitor<T> when you need to respond to configuration changes in real time—rate-limiting thresholds, logging levels, or feature flags managed by an external system. The CurrentValue property always reflects the latest configuration, and OnChange callbacks allow you to react immediately when settings change. This is particularly valuable in containerized environments where configuration might be updated without restarting the pod.

Validation and error handling

Validation is not optional. Chain validation methods to catch errors at startup rather than in production:

csharp
builder.Services
    .AddOptions()
    .BindConfiguration("Database")
    .ValidateDataAnnotations() // Validates [Required], [Range], etc.
    .Validate(options =>
    {
        // Custom validation logic
        if (options.MaxConnections > 1000)
        {
            return false; // Validation fails
        }
        return true;
    }, "MaxConnections must be less than or equal to 1000")
    .ValidateOnStart(); // Throws if validation fails at startup

When validation fails and ValidateOnStart() is set, the application refuses to start. This is the correct behavior—a misconfigured application should fail loudly during deployment, not silently fail in production.

Common mistakes

  • Forgetting ValidateOnStart(): Without it, invalid configuration might go unnoticed until a user triggers the code path. Always validate at startup.
  • Using IOptions when you need IOptionsSnapshot: If configuration changes during the application lifetime, IOptions will continue serving stale values. Know which scenarios require re-reading.
  • Over-using IOptionsMonitor: Change monitoring has overhead. Use it only when you genuinely need live updates. For static configuration, IOptions is sufficient and more efficient.
  • Mixing configuration classes: Create separate classes for logically distinct sections (SmtpOptions, DatabaseOptions, LoggingOptions). Don't dump everything into one mega-options class.
  • Injecting IConfiguration directly in services: This defeats the purpose of the Options pattern. Always bind to a typed class and inject the options type instead.

Key takeaways

  • Bind configuration sections to strongly-typed classes once during startup—no scattered IConfiguration reads throughout the codebase.
  • IOptions<T> is a singleton, validated once at startup; use it for static configuration that never changes.
  • IOptionsSnapshot<T> is scoped, re-evaluated per request; use it for configuration that changes but should be consistent within a request.
  • IOptionsMonitor<T> is singleton, always current with change callbacks; use it for live-updating configuration like feature flags.
  • Always validate with data annotations and custom validators, and always call ValidateOnStart() to catch errors during deployment, not production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles