Options Pattern in .NET Core
Reading Time: 4 minutes
The Problem
Configuration often starts small in .NET applications and gets messy fast. At first, reading values directly from IConfiguration feels fine:
var connectionString = configuration["ConnectionStrings:Default"];
var maxRetries = int.Parse(configuration["Resilience:MaxRetries"]!);
var timeout = int.Parse(configuration["Resilience:TimeoutSeconds"]!);
But this approach creates common issues:
- Magic strings scattered around the codebase
- Repeated parsing and null checks
- Harder unit tests
- No centralized validation
- Runtime bugs from missing or invalid config values
As applications grow, these problems increase maintenance cost and production risk.
The Solution
The Options Pattern in .NET Core maps configuration sections into strongly typed classes and injects them through DI.
This gives you:
- Strong typing and IntelliSense
- Centralized configuration model
- Better testability
- Built-in validation support
- Optional runtime reload behavior
Step 1: Define a Settings Class
Create a POCO that represents your configuration section:
public sealed class EmailOptions
{
public const string SectionName = "Email";
public string Provider { get; set; } = "Smtp";
public string ApiKey { get; set; } = string.Empty;
public string FromAddress { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; } = 30;
}
And configure it in appsettings.json:
{
"Email": {
"Provider": "SendGrid",
"ApiKey": "your-api-key",
"FromAddress": "[email protected]",
"TimeoutSeconds": 30
}
}
Step 2: Register Options in Program.cs
Bind the section in your startup pipeline:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection(EmailOptions.SectionName));
var app = builder.Build();
Now EmailOptions is available via DI.
Step 3: Consume with IOptions
Use IOptions<T> when configuration is stable for the app lifetime:
using Microsoft.Extensions.Options;
public sealed class EmailSender
{
private readonly EmailOptions _options;
public EmailSender(IOptions<EmailOptions> options)
{
_options = options.Value;
}
public Task SendWelcomeAsync(string to)
{
// Use _options.Provider, _options.ApiKey, etc.
return Task.CompletedTask;
}
}
Which Interface Should You Use?
.NET gives three common ways to consume options:
| Type | Lifetime Behavior | Typical Use |
|---|---|---|
IOptions<T> | Singleton snapshot at startup | Most apps, stable config |
IOptionsSnapshot<T> | Recomputed per request (scoped) | ASP.NET Core request-based updates |
IOptionsMonitor<T> | Change notifications + current value | Background services, dynamic config |
IOptionsSnapshot
Use this when you want updated values per request:
public sealed class PricingService
{
private readonly FeatureFlagsOptions _flags;
public PricingService(IOptionsSnapshot<FeatureFlagsOptions> flags)
{
_flags = flags.Value;
}
}
IOptionsMonitor
Use this when values may change while the app is running:
public sealed class CacheWarmupService : BackgroundService
{
private readonly IOptionsMonitor<CacheOptions> _monitor;
public CacheWarmupService(IOptionsMonitor<CacheOptions> monitor)
{
_monitor = monitor;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
_monitor.OnChange(updated =>
{
// React to updated configuration values
Console.WriteLine($"Cache size changed to: {updated.MaxItems}");
});
return Task.CompletedTask;
}
}
Add Validation (Highly Recommended)
Validation catches bad config early, ideally at startup.
Data Annotations
using System.ComponentModel.DataAnnotations;
public sealed class EmailOptions
{
public const string SectionName = "Email";
[Required]
public string Provider { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string FromAddress { get; set; } = string.Empty;
[Range(1, 120)]
public int TimeoutSeconds { get; set; } = 30;
[Required]
[MinLength(10)]
public string ApiKey { get; set; } = string.Empty;
}
Register with validation:
builder.Services
.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
ValidateOnStart() is especially useful in production because the app fails fast instead of failing under traffic.
Custom Validation Rule
For complex rules, add .Validate(...):
builder.Services
.AddOptions<EmailOptions>()
.Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
.Validate(o =>
o.Provider is "Smtp" or "SendGrid" or "MailKit",
"Email:Provider must be one of Smtp, SendGrid, or MailKit")
.ValidateOnStart();
Common Mistakes to Avoid
- Injecting
IConfigurationeverywhere instead of a typed options model - Skipping validation and discovering issues only in production
- Using
IOptionsSnapshot<T>inside singleton services (lifetime mismatch) - Mixing unrelated settings into one giant options class
- Not documenting required environment variables for each options section
Recommended Structure
A clean pattern is one options class per bounded concern:
EmailOptionsJwtOptionsStorageOptionsFeatureFlagsOptions
Keep each class focused and colocated with the feature it serves.
Summary
The Options Pattern is one of the simplest ways to improve architecture quality in .NET Core apps:
- Strongly typed config over stringly-typed lookups
- Cleaner services with fewer parsing concerns
- Better testability and maintainability
- Startup validation for safer deployments
If your application still reads raw config values across controllers and services, moving to Options is a quick win with long-term payoff.
References
- Options pattern in ASP.NET Core: https://learn.microsoft.com/aspnet/core/fundamentals/configuration/options
- Configuration in ASP.NET Core: https://learn.microsoft.com/aspnet/core/fundamentals/configuration/
- Options in .NET extensions: https://learn.microsoft.com/dotnet/core/extensions/options