The Options pattern in .NET is often presented as a way to load configuration from appsettings.json, but its real power lies in how it decouples your application’s configuration needs from the underlying configuration sources, allowing for complex validation and conditional loading that most developers never touch.
Let’s see it in action. Imagine a simple EmailService that needs SMTP server details.
// Program.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((hostingContext, config) =>
{
config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
config.AddEnvironmentVariables();
})
.ConfigureServices((hostContext, services) =>
{
// Register the options class and bind it to a configuration section
services.AddOptions<SmtpSettings>()
.Bind(hostContext.Configuration.GetSection("SmtpSettings"))
.ValidateDataAnnotations() // Enable validation based on attributes
.Validate(settings => !string.IsNullOrEmpty(settings.Host), "SMTP host cannot be empty.") // Custom validation
.Services.AddTransient<IEmailService, EmailService>();
});
}
// appsettings.json
{
"SmtpSettings": {
"Host": "smtp.example.com",
"Port": 587,
"Username": "user@example.com",
"Password": "securepassword"
}
}
// ISmtpSettings.cs (Interface for settings)
public interface ISmtpSettings
{
string Host { get; set; }
int Port { get; set; }
string Username { get; set; }
string Password { get; set; }
}
// SmtpSettings.cs (Concrete implementation for options)
using System.ComponentModel.DataAnnotations;
public class SmtpSettings : ISmtpSettings
{
[Required(ErrorMessage = "SMTP host is required.")]
public string Host { get; set; }
[Range(1, 65535, ErrorMessage = "SMTP port must be between 1 and 65535.")]
public int Port { get; set; }
[Required(ErrorMessage = "SMTP username is required.")]
public string Username { get; set; }
[Required(ErrorMessage = "SMTP password is required.")]
public string Password { get; set; }
}
// EmailService.cs (Service that uses the options)
using Microsoft.Extensions.Options;
public interface IEmailService
{
void SendEmail(string to, string subject, string body);
}
public class EmailService : IEmailService
{
private readonly ISmtpSettings _settings;
public EmailService(IOptions<SmtpSettings> smtpSettings)
{
// IOptions<T> provides the configuration values.
// IOptionsSnapshot<T> or IOptionsMonitor<T> would be used for dynamic reloads.
_settings = smtpSettings.Value;
}
public void SendEmail(string to, string subject, string body)
{
Console.WriteLine($"Sending email to {to} via {_settings.Host}:{_settings.Port}...");
// Actual email sending logic would go here, using _settings.Host, _settings.Port, etc.
Console.WriteLine("Email sent successfully.");
}
}
// Another service that might use the same settings
public class ReportGenerator
{
private readonly ISmtpSettings _smtpSettings;
public ReportGenerator(IOptions<SmtpSettings> smtpSettings)
{
_smtpSettings = smtpSettings.Value;
}
public void GenerateAndSendReport()
{
Console.WriteLine($"Report generated. Preparing to send via {_smtpSettings.Host}...");
// ...
}
}
The AddOptions<T>() method registers an options class (SmtpSettings) with the dependency injection container. .Bind() then tells it to map a section from the configuration (SmtpSettings in appsettings.json) to this class. .ValidateDataAnnotations() automatically checks for [Required], [Range], etc., on your options class. You can also add custom validation logic with .Validate(), as shown with the Host check. When EmailService is resolved, IOptions<SmtpSettings> is injected, and .Value gives you the bound and validated configuration object.
This pattern allows you to define a clear contract for your configuration (ISmtpSettings and SmtpSettings) and enforce its correctness at startup. Instead of scattering ConfigurationManager.AppSettings["Key"] calls throughout your code, you have strongly-typed objects that are validated. This makes refactoring easier, reduces runtime errors, and improves code readability by making configuration dependencies explicit.
The real magic happens when you realize that IOptions<T> is just the simplest form. IOptionsSnapshot<T> provides values that are evaluated once per request (in web contexts), which is crucial for scenarios where configuration might change mid-request or you need different settings per incoming request. IOptionsMonitor<T> is even more powerful, providing values that can be re-evaluated dynamically if the configuration source changes (e.g., appsettings.json is reloaded). To enable this dynamic reload, you’d typically use config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) in your CreateHostBuilder and then inject IOptionsMonitor<SmtpSettings> into your services. The IOptionsMonitor will automatically pick up changes from appsettings.json without requiring an application restart, and its CurrentValue property will reflect the latest settings.
When you use IOptions<T> and Bind a section that doesn’t exist in your configuration sources (like appsettings.json or environment variables), the SmtpSettings object will be created with its default values (e.g., null for strings, 0 for ints). If you have ValidateDataAnnotations() or other validation configured, the application will fail to start with an OptionsValidationException, indicating that required configuration is missing.
The next logical step is to explore how to manage configurations across different environments using distinct appsettings.{EnvironmentName}.json files and how IOptionsMonitor can be leveraged for dynamic configuration updates.