.NET & C# Development · Lesson 42 of 92

Options Pattern — Strongly-Typed Config With Validation

Every time you inject IConfiguration into a service, you hand that service a master key to your entire configuration — every connection string, every API key, every secret — even if it only needs one value.

This is not a theoretical concern. It's a real surface area problem that the Options Pattern was designed to eliminate.


The Problem With Direct IConfiguration Injection

C#
// ❌ This service only needs a city name — but it can read everything
public class WeatherService
{
    private readonly IConfiguration _config;

    public WeatherService(IConfiguration config)
    {
        _config = config;
    }

    public string GetDefaultCity()
        => _config["Weather:DefaultCity"]; // fine

    // But nothing stops it from doing this:
    // _config["ConnectionStrings:DefaultConnection"]
    // _config["Stripe:SecretKey"]
    // _config["Jwt:Secret"]
    // Every secret in appsettings.json is accessible
}

The problem compounds when you consider the full picture:

appsettings.json (or environment variables):
  ConnectionStrings:DefaultConnection  → SQL Server connection string with credentials
  Stripe:SecretKey                     → Payment processing API key
  Jwt:Secret                           → Token signing secret
  SendGrid:ApiKey                      → Email service key
  Weather:DefaultCity                  → "London"
  Weather:TemperatureUnit              → "Celsius"

IConfiguration injection gives WeatherService access to ALL of this.
The Options Pattern gives WeatherService access to ONLY this:
  Weather:DefaultCity
  Weather:TemperatureUnit

Beyond the security surface area, there are three more problems:

No compile-time safety: _config["Wether:DefaultCity"] silently returns null at runtime. The typo survives until a user hits the endpoint.

No validation at startup: If a required key is missing, you find out when a request fails — not when the app boots.

No type coercion: Everything comes out as string. You cast, parse, or forget to parse and get NullReferenceException in production.


The Options Pattern: Core Concept

┌────────────────────────────────────────────────────────────────┐
│                     appsettings.json                           │
│                                                                │
│  "Weather": {                                                  │
│    "DefaultCity": "London",                                    │
│    "TemperatureUnit": "Celsius",                               │
│    "CacheSeconds": 300                                         │
│  }                                                             │
└──────────────────────┬─────────────────────────────────────────┘
                       │ binds to
                       ▼
┌────────────────────────────────────────────────────────────────┐
│                    WeatherOptions (POCO)                        │
│                                                                │
│  public string DefaultCity { get; set; }                       │
│  public string TemperatureUnit { get; set; }                   │
│  public int CacheSeconds { get; set; }                         │
└──────────────────────┬─────────────────────────────────────────┘
                       │ injected as
                       ▼
┌────────────────────────────────────────────────────────────────┐
│                    WeatherService                               │
│                                                                │
│  IOptions — access ONLY these three values     │
│  Nothing else. Not Stripe keys. Not connection strings.        │
└────────────────────────────────────────────────────────────────┘

Setting It Up

Step 1: Define the Options Class

C#
// WeatherOptions.cs — a plain C# class, no framework dependencies
public class WeatherOptions
{
    // Convention: the section name in appsettings matches the class name (without "Options")
    public const string SectionName = "Weather";

    public string DefaultCity { get; set; } = "London";
    public string TemperatureUnit { get; set; } = "Celsius";
    public int CacheSeconds { get; set; } = 300;
}

Step 2: Bind in Program.cs

C#
// Program.cs
builder.Services.Configure<WeatherOptions>(
    builder.Configuration.GetSection(WeatherOptions.SectionName));

Step 3: Inject in the Service

C#
// ✅ Service receives only its own config — nothing else
public class WeatherService
{
    private readonly WeatherOptions _options;

    public WeatherService(IOptions<WeatherOptions> options)
    {
        _options = options.Value;
    }

    public string GetDefaultCity() => _options.DefaultCity;
    public int GetCacheDuration() => _options.CacheSeconds;
    // Cannot accidentally read Stripe keys or connection strings
}

The Three Interfaces: IOptions, IOptionsSnapshot, IOptionsMonitor

This is where most guides stop. Understanding which one to use changes the behavior of your configuration entirely.

┌──────────────────────────────────────────────────────────────────────┐
│                                                                      │
│  IOptions          — singleton lifetime, fixed at startup         │
│  IOptionsSnapshot  — scoped lifetime, reloads per request         │
│  IOptionsMonitor   — singleton lifetime, reloads at any time      │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘

IOptions<T> — The Default (Use This Most of the Time)

C#
public class SmtpService
{
    private readonly SmtpOptions _options;

    // IOptions<T> is singleton — .Value is computed once at startup
    public SmtpService(IOptions<SmtpOptions> options)
    {
        _options = options.Value; // safe to store — won't change
    }
}
Lifetime:     Singleton (registered once, same instance for app lifetime)
Reloads:      No — config is read once at startup
Best for:     Database connection strings, SMTP config, API base URLs,
              anything that requires an app restart to change anyway
Performance:  Fastest — no overhead per request

IOptionsSnapshot<T> — Scoped, Per-Request Reload

C#
public class FeatureFlagService
{
    private readonly FeatureFlags _flags;

    // IOptionsSnapshot<T> is scoped — new instance per request
    // If appsettings.json changed since last request, .Value reflects the new values
    public FeatureFlagService(IOptionsSnapshot<FeatureFlags> options)
    {
        _flags = options.Value;
    }

    public bool IsEnabled(string feature)
        => _flags.EnabledFeatures.Contains(feature);
}
Lifetime:     Scoped (new instance per HTTP request)
Reloads:      Yes — reflects config changes between requests
Best for:     Feature flags loaded from appsettings (not a DB)
              A/B test configuration reloaded without restart
Performance:  Slightly more overhead than IOptions — per-request re-evaluation

Cannot inject into: Singleton services
(singleton outlives scoped — IOptionsSnapshot in a singleton is a captive dependency error)

IOptionsMonitor<T> — Singleton With Reactive Reload

C#
public class RateLimitService
{
    private readonly IOptionsMonitor<RateLimitOptions> _monitor;

    // IOptionsMonitor<T> is singleton but reflects live config changes
    public RateLimitService(IOptionsMonitor<RateLimitOptions> monitor)
    {
        _monitor = monitor;

        // Optional: react immediately when config changes
        _monitor.OnChange(opts =>
        {
            // clear caches, reset counters, log the change
            Console.WriteLine($"Rate limit updated: {opts.RequestsPerMinute} req/min");
        });
    }

    public int GetLimit() => _monitor.CurrentValue.RequestsPerMinute;
    // CurrentValue always returns the latest config — live reload
}
Lifetime:     Singleton
Reloads:      Yes — reflects changes immediately via file watcher
Best for:     Rate limits, timeouts, thresholds you want to tune without restarting
              Feature flags in background services (can't use IOptionsSnapshot)
Performance:  Small overhead for the change detection mechanism

When to Use Each

                    Does config change at runtime
                    without restarting the app?
                              │
                   ┌──────────┴──────────┐
                   No                    Yes
                   │                     │
                   ▼                     ▼
            IOptions         Is this used in a
            ← use this          singleton service?
            for most things            │
                               ┌───────┴───────┐
                              Yes               No
                               │                │
                               ▼                ▼
                      IOptionsMonitor  IOptionsSnapshot
                      (singleton-safe)    (scoped — per request)

Validation: Catch Missing Config at Startup

Without validation, a missing config key fails when the first request touches the code path that needs it — possibly at 3 AM in production.

C#
// ❌ No validation — failure happens at runtime, on the first user who needs it
public class PaymentService
{
    private readonly string _apiKey;

    public PaymentService(IOptions<StripeOptions> options)
    {
        _apiKey = options.Value.ApiKey; // null if key is missing — fails on first payment
    }
}

DataAnnotations Validation

C#
public class StripeOptions
{
    public const string SectionName = "Stripe";

    [Required]
    public string ApiKey { get; set; } = string.Empty;

    [Required]
    [Url]
    public string WebhookEndpoint { get; set; } = string.Empty;

    [Range(1, 10)]
    public int MaxRetries { get; set; } = 3;
}
C#
// Program.cs — validate on startup, fail fast before accepting any requests
builder.Services
    .AddOptions<StripeOptions>()
    .BindConfiguration(StripeOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart(); // ← throws at startup if Required fields are missing
App start without STRIPE__APIKEY set:

❌ Without ValidateOnStart():
  App starts successfully
  First payment request → NullReferenceException in StripeClient
  Users see 500 errors, support tickets filed, on-call paged

✅ With ValidateOnStart():
  App fails to start
  Console: "DataAnnotation validation failed for 'StripeOptions'.
            ApiKey: The ApiKey field is required."
  Deployment pipeline catches it — bad config never reaches production

Custom Validation with IValidateOptions<T>

For business logic that DataAnnotations can't express:

C#
public class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
    public ValidateOptionsResult Validate(string? name, SmtpOptions options)
    {
        var errors = new List<string>();

        if (string.IsNullOrWhiteSpace(options.Host))
            errors.Add("Host is required");

        if (options.Port is not (25 or 465 or 587))
            errors.Add("Port must be 25, 465, or 587");

        if (options.UseSsl && options.Port == 25)
            errors.Add("SSL is not supported on port 25");

        return errors.Count > 0
            ? ValidateOptionsResult.Fail(errors)
            : ValidateOptionsResult.Success;
    }
}

// Registration
builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();
builder.Services
    .AddOptions<SmtpOptions>()
    .BindConfiguration(SmtpOptions.SectionName)
    .ValidateOnStart();

Organizing Options in a Real Application

For larger apps, each feature module registers its own options:

src/
├── Features/
│   ├── Payments/
│   │   ├── StripeOptions.cs
│   │   └── PaymentsModule.cs   ← registers StripeOptions
│   ├── Email/
│   │   ├── SmtpOptions.cs
│   │   └── EmailModule.cs      ← registers SmtpOptions
│   └── Auth/
│       ├── JwtOptions.cs
│       └── AuthModule.cs       ← registers JwtOptions
└── Program.cs                  ← calls each module's registration
C#
// PaymentsModule.cs
public static class PaymentsModule
{
    public static IServiceCollection AddPayments(
        this IServiceCollection services,
        IConfiguration config)
    {
        services
            .AddOptions<StripeOptions>()
            .BindConfiguration(StripeOptions.SectionName)
            .ValidateDataAnnotations()
            .ValidateOnStart();

        services.AddScoped<IPaymentService, StripePaymentService>();
        return services;
    }
}

// Program.cs — clean, no config details at the host level
builder.Services.AddPayments(builder.Configuration);
builder.Services.AddEmail(builder.Configuration);
builder.Services.AddAuth(builder.Configuration);

Secrets and Environment-Specific Config

The Options Pattern composes across all configuration sources — appsettings.json, environment variables, Azure Key Vault, AWS Secrets Manager. The binding is the same regardless of source.

Priority order (later overrides earlier):
  appsettings.json
  appsettings.{Environment}.json
  Environment variables
  Azure Key Vault / AWS Secrets Manager
  User Secrets (development only)

All bind to the same options class.
Your service doesn't know or care where the value came from.
JSON
// appsettings.json — non-secret defaults
{
  "Stripe": {
    "MaxRetries": 3,
    "WebhookEndpoint": "https://api.yourapp.com/webhooks/stripe"
  }
}
# Environment variable — overrides the json value for ApiKey
# .NET maps __ (double underscore) to : (section separator)
STRIPE__APIKEY=sk_live_actual_production_key_here
C#
// StripeOptions binds from both sources automatically
// MaxRetries → from appsettings.json
// WebhookEndpoint → from appsettings.json
// ApiKey → from environment variable (overrides if also in json)
public class StripeOptions
{
    public const string SectionName = "Stripe";
    [Required] public string ApiKey { get; set; } = string.Empty;
    public string WebhookEndpoint { get; set; } = string.Empty;
    public int MaxRetries { get; set; } = 3;
}

Keep secrets out of appsettings.json entirely. Set them as environment variables in production (Azure App Service → Configuration → Application Settings, or injected via Key Vault references). The Options Pattern binds regardless of source.


Named Options

When you need multiple instances of the same options type — two SMTP providers, two external APIs:

JSON
{
  "Smtp": {
    "Transactional": {
      "Host": "smtp.sendgrid.net",
      "Port": 587
    },
    "Marketing": {
      "Host": "smtp.mailchimp.com",
      "Port": 587
    }
  }
}
C#
// Registration with names
builder.Services
    .AddOptions<SmtpOptions>("Transactional")
    .BindConfiguration("Smtp:Transactional");

builder.Services
    .AddOptions<SmtpOptions>("Marketing")
    .BindConfiguration("Smtp:Marketing");

// Injection — resolve by name with IOptionsMonitor<T>
public class EmailDispatcher
{
    private readonly SmtpOptions _transactional;
    private readonly SmtpOptions _marketing;

    public EmailDispatcher(IOptionsMonitor<SmtpOptions> monitor)
    {
        _transactional = monitor.Get("Transactional");
        _marketing = monitor.Get("Marketing");
    }
}

Quick Reference

C#
// 1. Define options class
public class MyOptions
{
    public const string SectionName = "MySection";
    [Required] public string RequiredValue { get; set; } = string.Empty;
    public int OptionalValue { get; set; } = 42;
}

// 2. Register with validation
builder.Services
    .AddOptions<MyOptions>()
    .BindConfiguration(MyOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

// 3. Inject with the right interface
public class MyService
{
    private readonly MyOptions _options;
    public MyService(IOptions<MyOptions> options) => _options = options.Value;
}

| Interface | Lifetime | Reloads? | Use For | |---|---|---|---| | IOptions<T> | Singleton | No | DB connections, API base URLs, static config | | IOptionsSnapshot<T> | Scoped | Per request | Feature flags in scoped services | | IOptionsMonitor<T> | Singleton | Live | Rate limits, thresholds, feature flags in singletons |

Two minutes to set up a typed options class. Compile-time safety on every config key. Startup validation before any user ever hits your app. Each service sees only the config it needs.

That's the trade.