- Published on
Understanding the Options Pattern in .NET
- Authors
- Name
- Jeevan Wijerathna
- @iamjeevanvj
Understanding the Options Pattern in .NET
Configuration is a fundamental concern in any modern application. Hard‑coding settings or scattering magic strings throughout your code makes maintenance difficult and error‑prone. The Options Pattern in .NET provides a clean, type‑safe way to represent related settings as classes, bind them to configuration sources, and consume them via dependency injection.
The options pattern uses classes to provide strongly‑typed access to groups of related settings. When configuration settings are isolated by scenario into separate classes, your app adheres to the Interface Segregation Principle (classes depend only on the settings they use) and Separation of Concerns (settings for different parts of the app aren't coupled).
Why Use the Options Pattern?
- Strong typing
Avoid magic strings and casting. Configuration is bound directly to properties on a class. - Dependency Injection (DI) integration
Options classes are registered in DI and can be injected where needed. - Lifetime control
Choose between singleton (IOptions<T>
), scoped (IOptionsSnapshot<T>
), or change‑aware (IOptionsMonitor<T>
) lifetimes. - Validation
Validate options at startup or runtime using data annotations or custom validators. - Named Options
Support multiple named configurations of the same type.
Defining an Options Class
Given an appsettings.json
snippet:
{ "EmailSettings": { "SmtpServer": "smtp.example.com", "Port": 587, "UseSsl": true }}
Create a corresponding class:
public class EmailSettings{ public const string SectionName = "EmailSettings";
public string SmtpServer { get; set; } = string.Empty; public int Port { get; set; } public bool UseSsl { get; set; }}
- The class must be non‑abstract with a public parameterless constructor.
- Only public read/write properties are bound.
- A
const string SectionName
helps avoid magic strings when binding.
Registering Options with DI
In Program.cs
(or Startup.cs
for older versions), bind the configuration section:
var builder = WebApplication.CreateBuilder(args);
// Bind EmailSettings to the "EmailSettings" sectionbuilder.Services .AddOptions<EmailSettings>() .Bind(builder.Configuration.GetSection(EmailSettings.SectionName)) .ValidateDataAnnotations(); // optional, for DataAnnotations validation
var app = builder.Build();
AddOptions<T>()
returns anOptionsBuilder<T>
, enabling fluent configuration.Bind(...)
ties the class to the JSON (or other) configuration provider.ValidateDataAnnotations()
(requiresMicrosoft.Extensions.Options.DataAnnotations
package) enforces[Required]
,[Range]
, etc.
Consuming Options
IOptions<T>
Inject IOptions<EmailSettings>
for singleton consumption:
public class EmailService{ private readonly EmailSettings _settings;
public EmailService(IOptions<EmailSettings> options) { _settings = options.Value; }}
IOptions<T>.Value
is evaluated once when accessed.- Suitable for transient or scoped services that don't need live reloads.
IOptionsSnapshot<T>
Use IOptionsSnapshot<EmailSettings>
in scoped services (e.g., per‑request in ASP.NET Core):
public class ScopedEmailService{ private readonly EmailSettings _settings;
public ScopedEmailService(IOptionsSnapshot<EmailSettings> options) { _settings = options.Value; }}
- Options are computed once per scope and cached.
- Supports reloading when configuration sources change (e.g., updated JSON file) between scopes.
IOptionsMonitor<T>
For singleton services that need to react to changes at runtime:
public class MonitorEmailService{ private readonly IOptionsMonitor<EmailSettings> _monitor;
public MonitorEmailService(IOptionsMonitor<EmailSettings> monitor) { _monitor = monitor; _monitor.OnChange(OnSettingsChanged); }
private void OnSettingsChanged(EmailSettings newSettings) { // React to updated settings }}
CurrentValue
always reflects the latest configuration.- Raise
OnChange
events when underlying configuration files (JSON, XML, etc.) change.
Named Options
When you need multiple configurations of the same type—say, two SMTP profiles—you can use named options:
{ "EmailSettings": { "Primary": { "SmtpServer": "...", "Port": 587, "UseSsl": true }, "Backup": { "SmtpServer": "...", "Port": 25, "UseSsl": false } }}
Register both:
builder.Services .AddOptions<EmailSettings>("Primary") .Bind(builder.Configuration.GetSection("EmailSettings:Primary"));
builder.Services .AddOptions<EmailSettings>("Backup") .Bind(builder.Configuration.GetSection("EmailSettings:Backup"));
Consume via IOptionsSnapshot<EmailSettings>
:
public class MultiEmailService{ private readonly EmailSettings _primary; private readonly EmailSettings _backup;
public MultiEmailService(IOptionsSnapshot<EmailSettings> options) { _primary = options.Get("Primary"); _backup = options.Get("Backup"); }}
Named options are case‑sensitive and support all the same features (validation, monitoring) as the default.
Validating Options
Data Annotations
Decorate your options class:
public class EmailSettings{ public const string SectionName = "EmailSettings";
[Required] public string SmtpServer { get; set; } = string.Empty; [Range(1, 65_535)] public int Port { get; set; } public bool UseSsl { get; set; }}
And register:
builder.Services .AddOptions<EmailSettings>() .Bind(...) .ValidateDataAnnotations() .ValidateOnStart(); // throws on startup if invalid
Validation errors throw an OptionsValidationException
.
Custom Validators
Implement IValidateOptions<T>
for complex rules:
public class EmailSettingsValidator : IValidateOptions<EmailSettings>{ public ValidateOptionsResult Validate(string? name, EmailSettings options) { if (!options.SmtpServer.StartsWith("smtp.")) return ValidateOptionsResult.Fail("SmtpServer must start with smtp.");
return ValidateOptionsResult.Success; }}
// Registrationbuilder.Services .Configure<EmailSettings>(...) .Services.TryAddEnumerable( ServiceDescriptor.Singleton< IValidateOptions<EmailSettings>, EmailSettingsValidator>());
This approach decouples validation logic from the options class itself.
Post-Configuration and Advanced Scenarios
-
Post-Configure
Override or augment bound values after allConfigure
calls:builder.Services.PostConfigure<EmailSettings>(opts =>{if (opts.Port == 0) opts.Port = 25;}); -
OptionsBuilder API
LeverageOptionsBuilder<T>
to combine multiple configuration delegates, validation, and named options in a fluent chain. -
Configuration Delegates with Services
Inject other services to compute option values:builder.Services.AddOptions<MyOptions>().Configure<ILoggerFactory>((opts, loggerFactory) =>{opts.LogLevel = loggerFactory.CreateLogger("default").IsEnabled(LogLevel.Debug)? LogLevel.Debug: LogLevel.Information;});
Sample End-to-End Example
-
appsettings.json
{"EmailSettings": {"SmtpServer": "smtp.example.com","Port": 587,"UseSsl": true}} -
Program.cs
var builder = WebApplication.CreateBuilder(args);// Bind and validate EmailSettingsbuilder.Services.AddOptions<EmailSettings>().Bind(builder.Configuration.GetSection(EmailSettings.SectionName)).ValidateDataAnnotations().Validate(opts => opts.Port > 0, "Port must be greater than zero").ValidateOnStart();builder.Services.AddTransient<EmailService>();var app = builder.Build();app.MapGet("/", (EmailService svc) =>{// Use svc to send email or inspect settingsreturn Results.Ok();});app.Run(); -
EmailService.cs
public class EmailService{private readonly EmailSettings _settings;public EmailService(IOptions<EmailSettings> options)=> _settings = options.Value;public void Send(string to, string subject, string body){using var client = new SmtpClient(_settings.SmtpServer, _settings.Port){EnableSsl = _settings.UseSsl};// send logic…}}
Summary
By following the Options Pattern, you achieve strong typing, centralized configuration, easy validation, and flexible lifetimes, all integrated seamlessly with .NET's dependency injection system. Whether you're building microservices, web apps, or libraries, the options pattern will help keep your configuration robust, maintainable, and testable.