Back to blog
Backend Systemsintermediate

The .NET Options Pattern: Stop Injecting IConfiguration Into Your Services

Injecting IConfiguration gives every class in your app access to every secret, connection string, and API key. The Options Pattern fixes this with type safety, startup validation, and minimal-privilege config access. This guide covers IOptions, IOptionsSnapshot, and IOptionsMonitor — and exactly when to use each.

LearnixoApril 19, 202610 min read
.NETC#ASP.NET CoreConfigurationSecurityBest PracticesDependency Injection
Share:𝕏

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.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.