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.