.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
// ❌ 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:TemperatureUnitBeyond 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
// 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
// Program.cs
builder.Services.Configure<WeatherOptions>(
builder.Configuration.GetSection(WeatherOptions.SectionName));Step 3: Inject in the Service
// ✅ 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)
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 requestIOptionsSnapshot<T> — Scoped, Per-Request Reload
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
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.
// ❌ 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
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;
}// 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 missingApp 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 productionCustom Validation with IValidateOptions<T>
For business logic that DataAnnotations can't express:
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// 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.// 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// 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:
{
"Smtp": {
"Transactional": {
"Host": "smtp.sendgrid.net",
"Port": 587
},
"Marketing": {
"Host": "smtp.mailchimp.com",
"Port": 587
}
}
}// 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
// 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.