
Unlock the Options Pattern in .NET
Configuration management is one of the most critical aspects of building maintainable applications. The Options Pattern in .NET provides a powerful way to handle configuration with type safety, validation, and seamless dependency injection integration.
Why the Options Pattern Matters
Traditional configuration approaches often lead to:
- Magic strings scattered throughout your codebase
- No type safety for configuration values
- Runtime errors from invalid configuration
- Difficulty testing configuration-dependent code
The Options Pattern solves these problems by providing:
- Strongly-typed configuration models
- Compile-time safety for configuration access
- Built-in validation with meaningful error messages
- Easy testing with mock configurations
- Hot reload capabilities for configuration changes
Let's explore how to implement this pattern using our familiar coffee shop domain.
Basic Options Configuration
Configuration Model
// Configuration model public class CoffeeShopOptions { public const string ConfigurationSection = "CoffeeShop"; public string ShopName { get; set; } = "Default Coffee Shop"; public int MaxOrdersPerHour { get; set; } = 100; public decimal TaxRate { get; set; } = 0.08m; public TimeSpan OrderTimeout { get; set; } = TimeSpan.FromMinutes(5); public PricingOptions Pricing { get; set; } = new(); } public class PricingOptions { public decimal BasePrice { get; set; } = 2.50m; public Dictionary<string, decimal> ItemPrices { get; set; } = new() { { "Espresso", 2.50m }, { "Latte", 4.00m }, { "Cappuccino", 3.50m } }; }
Configuration File (appsettings.json)
{ "CoffeeShop": { "ShopName": "Premium Coffee Roasters", "MaxOrdersPerHour": 150, "TaxRate": 0.0875, "OrderTimeout": "00:10:00", "Pricing": { "BasePrice": 3.0, "ItemPrices": { "Espresso": 3.0, "Latte": 5.5, "Cappuccino": 4.75 } } } }
Options Registration and Validation
Basic Registration
// Program.cs
builder.Services.Configure<CoffeeShopOptions>(
builder.Configuration.GetSection(CoffeeShopOptions.ConfigurationSection));
Advanced Registration with Validation
// Add validation builder.Services.AddOptions<CoffeeShopOptions>() .Bind(builder.Configuration.GetSection(CoffeeShopOptions.ConfigurationSection)) .ValidateDataAnnotations() .Validate(options => options.MaxOrdersPerHour > 0, "MaxOrdersPerHour must be positive") .Validate(options => options.TaxRate >= 0 && options.TaxRate <= 1, "TaxRate must be between 0 and 1");
Custom Options Validator
public class CoffeeShopOptionsValidator : IValidateOptions<CoffeeShopOptions> { public ValidateOptionsResult Validate(string? name, CoffeeShopOptions options) { var failures = new List<string>(); if (string.IsNullOrWhiteSpace(options.ShopName)) failures.Add("ShopName is required"); if (options.MaxOrdersPerHour <= 0) failures.Add("MaxOrdersPerHour must be greater than 0"); if (options.TaxRate < 0 || options.TaxRate > 1) failures.Add("TaxRate must be between 0 and 1"); if (options.OrderTimeout <= TimeSpan.Zero) failures.Add("OrderTimeout must be positive"); // Validate pricing options if (options.Pricing.BasePrice <= 0) failures.Add("Pricing.BasePrice must be greater than 0"); // Validate notification options if (options.Pricing.ItemPrices.Any(kvp => kvp.Value <= 0)) failures.Add("All item prices must be greater than 0"); return failures.Any() ? ValidateOptionsResult.Fail(failures) : ValidateOptionsResult.Success; } } // Register the validator builder.Services.AddSingleton<IValidateOptions<CoffeeShopOptions>, CoffeeShopOptionsValidator>();
Using Options in Services
IOptions - Snapshot Configuration
Use IOptions<T>
when your configuration doesn't need to change during the application lifetime:
// Service using IOptions<T> - snapshot at registration time public class PricingService { private readonly CoffeeShopOptions _options; public PricingService(IOptions<CoffeeShopOptions> options) { _options = options.Value; } public decimal CalculatePrice(string itemName) { return _options.Pricing.ItemPrices.GetValueOrDefault(itemName, _options.Pricing.BasePrice); } public decimal ApplyTax(decimal subtotal) { return subtotal * _options.TaxRate; } public decimal CalculateTotal(List<string> items) { var subtotal = items.Sum(CalculatePrice); var tax = ApplyTax(subtotal); return subtotal + tax; } }
IOptionsMonitor - Live Configuration Updates
Use IOptionsMonitor<T>
when you need to react to configuration changes at runtime:
// Service using IOptionsMonitor<T> - updates when configuration changes public class OrderLimitService { private readonly IOptionsMonitor<CoffeeShopOptions> _optionsMonitor; private readonly ILogger<OrderLimitService> _logger; public OrderLimitService(IOptionsMonitor<CoffeeShopOptions> optionsMonitor, ILogger<OrderLimitService> logger) { _optionsMonitor = optionsMonitor; _logger = logger; // Subscribe to configuration changes _optionsMonitor.OnChange(OnOptionsChanged); } public bool CanAcceptOrder(int currentHourlyOrders) { var currentOptions = _optionsMonitor.CurrentValue; var canAccept = currentHourlyOrders < currentOptions.MaxOrdersPerHour; _logger.LogDebug("Order limit check: {CurrentOrders}/{MaxOrders} - Can accept: {CanAccept}", currentHourlyOrders, currentOptions.MaxOrdersPerHour, canAccept); return canAccept; } private void OnOptionsChanged(CoffeeShopOptions newOptions, string? name) { _logger.LogInformation("Configuration changed - MaxOrdersPerHour: {MaxOrders}, TaxRate: {TaxRate:P}", newOptions.MaxOrdersPerHour, newOptions.TaxRate); } }
IOptionsSnapshot - Per-Request Configuration
Use IOptionsSnapshot<T>
in scoped services when you need fresh configuration per request/scope:
public class OrderProcessingService { private readonly IOptionsSnapshot<CoffeeShopOptions> _optionsSnapshot; private readonly ILogger<OrderProcessingService> _logger; public OrderProcessingService(IOptionsSnapshot<CoffeeShopOptions> optionsSnapshot, ILogger<OrderProcessingService> logger) { _optionsSnapshot = optionsSnapshot; _logger = logger; } public async Task<bool> ProcessOrderAsync(Order order) { var options = _optionsSnapshot.Value; // Use timeout from current configuration using var cts = new CancellationTokenSource(options.OrderTimeout); try { // Simulate order processing await ProcessOrderInternalAsync(order, cts.Token); _logger.LogInformation("Order {OrderId} processed successfully within {Timeout}", order.Id, options.OrderTimeout); return true; } catch (OperationCanceledException) { _logger.LogWarning("Order {OrderId} processing timed out after {Timeout}", order.Id, options.OrderTimeout); return false; } } private async Task ProcessOrderInternalAsync(Order order, CancellationToken cancellationToken) { // Simulate processing work await Task.Delay(2000, cancellationToken); } }
Named Options for Multiple Configurations
When you need to manage multiple configurations of the same type, use named options:
Configuration Setup
{ "CoffeeShops": { "Downtown": { "ShopName": "Downtown Coffee", "MaxOrdersPerHour": 200, "TaxRate": 0.0875, "OrderTimeout": "00:15:00", "Pricing": { "BasePrice": 3.5, "ItemPrices": { "Espresso": 3.5, "Latte": 6.0, "Cappuccino": 5.25 } } }, "Airport": { "ShopName": "Airport Express Coffee", "MaxOrdersPerHour": 300, "TaxRate": 0.10, "OrderTimeout": "00:05:00", "Pricing": { "BasePrice": 4.0, "ItemPrices": { "Espresso": 4.0, "Latte": 7.0, "Cappuccino": 6.5 } } }, "University": { "ShopName": "Campus Coffee", "MaxOrdersPerHour": 150, "TaxRate": 0.08, "OrderTimeout": "00:20:00", "Pricing": { "BasePrice": 2.0, "ItemPrices": { "Espresso": 2.0, "Latte": 3.5, "Cappuccino": 3.0 } } } } }
Registration
// Program.cs builder.Services.Configure<CoffeeShopOptions>("Downtown", builder.Configuration.GetSection("CoffeeShops:Downtown")); builder.Services.Configure<CoffeeShopOptions>("Airport", builder.Configuration.GetSection("CoffeeShops:Airport")); builder.Services.Configure<CoffeeShopOptions>("University", builder.Configuration.GetSection("CoffeeShops:University"));
Usage with Named Options
public class MultiLocationOrderService { private readonly IOptionsMonitor<CoffeeShopOptions> _optionsMonitor; private readonly ILogger<MultiLocationOrderService> _logger; public MultiLocationOrderService(IOptionsMonitor<CoffeeShopOptions> optionsMonitor, ILogger<MultiLocationOrderService> logger) { _optionsMonitor = optionsMonitor; _logger = logger; } public bool CanAcceptOrderAtLocation(string location, int currentOrders) { var options = _optionsMonitor.Get(location); var canAccept = currentOrders < options.MaxOrdersPerHour; _logger.LogDebug("Location {Location}: {CurrentOrders}/{MaxOrders} - Can accept: {CanAccept}", location, currentOrders, options.MaxOrdersPerHour, canAccept); return canAccept; } public decimal CalculateTotalWithTax(string location, decimal subtotal) { var options = _optionsMonitor.Get(location); var tax = subtotal * options.TaxRate; var total = subtotal + tax; _logger.LogDebug("Location {Location}: Subtotal {Subtotal:C}, Tax {Tax:C} ({TaxRate:P}), Total {Total:C}", location, subtotal, tax, options.TaxRate, total); return total; } public IEnumerable<string> GetAllLocations() { return new[] { "Downtown", "Airport", "University" }; } public CoffeeShopOptions GetLocationOptions(string location) { return _optionsMonitor.Get(location); } }
Advanced Options Patterns
Environment-Specific Configuration
// Program.cs var environment = builder.Environment.EnvironmentName; builder.Services.AddOptions<CoffeeShopOptions>() .Bind(builder.Configuration.GetSection($"CoffeeShop:{environment}")) .ValidateOnStart() // Validate on application start .Validate(options => options.MaxOrdersPerHour > 0, "MaxOrdersPerHour must be positive");
Configuration Post-Processing
builder.Services.PostConfigure<CoffeeShopOptions>(options => { // Apply business rules or calculations after configuration binding if (options.Pricing.ItemPrices.Count == 0) { // Set default prices if none configured options.Pricing.ItemPrices = new Dictionary<string, decimal> { { "Espresso", options.Pricing.BasePrice }, { "Latte", options.Pricing.BasePrice * 1.5m }, { "Cappuccino", options.Pricing.BasePrice * 1.3m } }; } // Ensure tax rate is reasonable if (options.TaxRate > 0.25m) { options.TaxRate = 0.25m; // Cap at 25% } });
Options with Data Annotations
using System.ComponentModel.DataAnnotations; public class CoffeeShopOptions { public const string ConfigurationSection = "CoffeeShop"; [Required] [StringLength(100, MinimumLength = 1)] public string ShopName { get; set; } = "Default Coffee Shop"; [Range(1, 1000)] public int MaxOrdersPerHour { get; set; } = 100; [Range(0.0, 1.0)] public decimal TaxRate { get; set; } = 0.08m; [Range(typeof(TimeSpan), "00:01:00", "01:00:00")] public TimeSpan OrderTimeout { get; set; } = TimeSpan.FromMinutes(5); [Required] [ValidateObjectMembers] public PricingOptions Pricing { get; set; } = new(); } public class PricingOptions { [Range(0.01, 100.0)] public decimal BasePrice { get; set; } = 2.50m; [Required] public Dictionary<string, decimal> ItemPrices { get; set; } = new(); }
Testing with Options
Unit Testing Services with Options
[Test] public void PricingService_CalculatePrice_ReturnsCorrectPrice() { // Arrange var options = Microsoft.Extensions.Options.Options.Create(new CoffeeShopOptions { Pricing = new PricingOptions { BasePrice = 3.0m, ItemPrices = new Dictionary<string, decimal> { { "Espresso", 3.5m }, { "Latte", 5.0m } } } }); var service = new PricingService(options); // Act var espressoPrice = service.CalculatePrice("Espresso"); var unknownPrice = service.CalculatePrice("Unknown"); // Assert Assert.AreEqual(3.5m, espressoPrice); Assert.AreEqual(3.0m, unknownPrice); // Should use base price } [Test] public void PricingService_ApplyTax_CalculatesCorrectly() { // Arrange var options = Microsoft.Extensions.Options.Options.Create(new CoffeeShopOptions { TaxRate = 0.08m }); var service = new PricingService(options); // Act var tax = service.ApplyTax(10.0m); // Assert Assert.AreEqual(0.8m, tax); }
Integration Testing with Configuration
[Test] public void OrderLimitService_WithConfiguration_WorksCorrectly() { // Arrange var configuration = new ConfigurationBuilder() .AddJsonString(""" { "CoffeeShop": { "MaxOrdersPerHour": 100, "TaxRate": 0.08 } } """) .Build(); var services = new ServiceCollection(); services.Configure<CoffeeShopOptions>(configuration.GetSection("CoffeeShop")); services.AddLogging(); services.AddScoped<OrderLimitService>(); var serviceProvider = services.BuildServiceProvider(); var orderLimitService = serviceProvider.GetRequiredService<OrderLimitService>(); // Act & Assert Assert.IsTrue(orderLimitService.CanAcceptOrder(50)); Assert.IsFalse(orderLimitService.CanAcceptOrder(150)); }
Best Practices
1. Use Const for Section Names
public class CoffeeShopOptions { public const string ConfigurationSection = "CoffeeShop"; // ✅ Good // Don't use magic strings throughout your code }
2. Provide Sensible Defaults
public class CoffeeShopOptions { public string ShopName { get; set; } = "Default Coffee Shop"; // ✅ Good public int MaxOrdersPerHour { get; set; } = 100; // ✅ Good // Always provide defaults so the app can start even with minimal config }
3. Choose the Right Options Interface
- IOptions
: Static configuration that doesn't change - IOptionsMonitor
: Need to react to configuration changes - IOptionsSnapshot
: Fresh configuration per request/scope
4. Validate Early and Often
builder.Services.AddOptions<CoffeeShopOptions>() .Bind(configuration.GetSection("CoffeeShop")) .ValidateDataAnnotations() // ✅ Good - validate immediately .ValidateOnStart(); // ✅ Good - fail fast at startup
5. Use Strongly-Typed Configuration
// ✅ Good public decimal GetTaxRate() => _options.TaxRate; // ❌ Bad public decimal GetTaxRate() => _configuration.GetValue<decimal>("CoffeeShop:TaxRate");
Conclusion
The Options Pattern is a fundamental tool for building robust, maintainable .NET applications. It provides:
- Type Safety: Compile-time checking for configuration access
- Validation: Built-in and custom validation for configuration values
- Testability: Easy to mock and test configuration-dependent code
- Flexibility: Support for multiple configurations and runtime changes
- Best Practices: Encourages proper separation of concerns
By mastering the Options Pattern, you'll build applications that are more reliable, easier to test, and simpler to maintain. Whether you're building a simple web application or a complex enterprise system, the Options Pattern should be in your toolkit.