Section 5 of 6

The Options Pattern

🎯 What You'll Learn

  • What the Options Pattern is and why use it
  • Creating strongly-typed configuration classes
  • Registering options in Program.cs
  • Injecting options with IOptions, IOptionsSnapshot, IOptionsMonitor
  • Validating configuration with Data Annotations
  • Named options for multiple configurations
  • Post-configuration and validation
  • Best practices for InvenTrack options

What is the Options Pattern?

The Options Pattern is the recommended way to access configuration in ASP.NET Core. Instead of reading strings from IConfiguration, you bind configuration sections to strongly-typed classes.

💡 Why Use the Options Pattern?

Type Safety: Compile-time checking instead of runtime errors
IntelliSense: Auto-completion for configuration properties
Validation: Validate configuration at startup
Testability: Easy to mock in unit tests
Separation of Concerns: Configuration logic separate from business logic

Basic Usage

Step 1: Create an Options Class

JwtSettings.cs C#
namespace InvenTrack.Api.Options;

public class JwtSettings
{
    public string Secret { get; set; } = string.Empty;
    public string Issuer { get; set; } = string.Empty;
    public string Audience { get; set; } = string.Empty;
    public int ExpirationMinutes { get; set; }
}

Step 2: Configure in appsettings.json

appsettings.json JSON
{
  "JwtSettings": {
    "Secret": "your-secret-key-here-min-32-chars",
    "Issuer": "InvenTrack.Api",
    "Audience": "InvenTrack.Client",
    "ExpirationMinutes": 60
  }
}

Step 3: Register Options in Program.cs

Program.cs C#
builder.Services.Configure<JwtSettings>(
    builder.Configuration.GetSection("JwtSettings"));

Step 4: Inject and Use

AuthService.cs C#
using Microsoft.Extensions.Options;

public class AuthService
{
    private readonly JwtSettings _jwtSettings;

    public AuthService(IOptions<JwtSettings> jwtOptions)
    {
        _jwtSettings = jwtOptions.Value;
    }

    public string GenerateToken(string userId)
    {
        // Use _jwtSettings.Secret, _jwtSettings.Issuer, etc.
        var key = Encoding.UTF8.GetBytes(_jwtSettings.Secret);
        // ... token generation logic
    }
}

IOptions vs IOptionsSnapshot vs IOptionsMonitor

There are three interfaces for accessing options:

IOptions<T>

IOptions C#
public MyService(IOptions<JwtSettings> options)
{
    var settings = options.Value;
}
  • Lifetime: Singleton
  • Reloading: Does NOT reload when config changes
  • Use when: Configuration doesn't change during runtime
  • Best for: Most scenarios

IOptionsSnapshot<T>

IOptionsSnapshot C#
public MyService(IOptionsSnapshot<JwtSettings> options)
{
    var settings = options.Value;
}
  • Lifetime: Scoped (per request)
  • Reloading: Reloads on each request
  • Use when: Config might change between requests
  • Best for: Named options, per-request config

IOptionsMonitor<T>

IOptionsMonitor C#
public MyService(IOptionsMonitor<JwtSettings> options)
{
    var settings = options.CurrentValue;
    
    // Subscribe to changes
    options.OnChange(newSettings =>
    {
        Console.WriteLine("Configuration changed!");
    });
}
  • Lifetime: Singleton
  • Reloading: Reloads immediately when config changes
  • Use when: Need to react to config changes in real-time
  • Best for: Singleton services that need live updates
Feature IOptions IOptionsSnapshot IOptionsMonitor
Lifetime Singleton Scoped Singleton
Reloads ❌ No ✅ Per request ✅ Immediately
Named options ❌ No ✅ Yes ✅ Yes
Change notifications ❌ No ❌ No ✅ Yes
💡 Which One to Use?

Default choice: Use IOptions<T> for most scenarios
Per-request config: Use IOptionsSnapshot<T>
Real-time updates: Use IOptionsMonitor<T>

Validation with Data Annotations

You can validate options using Data Annotations:

JwtSettings.cs (with validation) C#
using System.ComponentModel.DataAnnotations;

public class JwtSettings
{
    [Required(ErrorMessage = "JWT Secret is required")]
    [MinLength(32, ErrorMessage = "JWT Secret must be at least 32 characters")]
    public string Secret { get; set; } = string.Empty;

    [Required]
    public string Issuer { get; set; } = string.Empty;

    [Required]
    public string Audience { get; set; } = string.Empty;

    [Range(1, 1440, ErrorMessage = "Expiration must be between 1 and 1440 minutes")]
    public int ExpirationMinutes { get; set; }
}

Enable Validation

Program.cs C#
builder.Services.AddOptions<JwtSettings>()
    .Bind(builder.Configuration.GetSection("JwtSettings"))
    .ValidateDataAnnotations()
    .ValidateOnStart();
  • ValidateDataAnnotations() - Enables Data Annotations validation
  • ValidateOnStart() - Validates at startup (fails fast)
⚠️ Fail Fast

Without ValidateOnStart(), validation only happens when options are first accessed. Use ValidateOnStart() to catch configuration errors at startup!

Custom Validation

For complex validation, implement IValidateOptions<T>:

JwtSettingsValidator.cs C#
using Microsoft.Extensions.Options;

public class JwtSettingsValidator : IValidateOptions<JwtSettings>
{
    public ValidateOptionsResult Validate(string? name, JwtSettings options)
    {
        var errors = new List<string>();

        if (string.IsNullOrWhiteSpace(options.Secret))
        {
            errors.Add("JWT Secret is required");
        }
        else if (options.Secret.Length < 32)
        {
            errors.Add("JWT Secret must be at least 32 characters");
        }

        if (options.ExpirationMinutes < 1 || options.ExpirationMinutes > 1440)
        {
            errors.Add("Expiration must be between 1 and 1440 minutes");
        }

        return errors.Count == 0
            ? ValidateOptionsResult.Success
            : ValidateOptionsResult.Fail(errors);
    }
}

Register Custom Validator

Program.cs C#
builder.Services.AddSingleton<IValidateOptions<JwtSettings>, JwtSettingsValidator>();

builder.Services.AddOptions<JwtSettings>()
    .Bind(builder.Configuration.GetSection("JwtSettings"))
    .ValidateOnStart();

Post-Configuration

You can modify options after they're bound from configuration:

Post-Configure C#
builder.Services.PostConfigure<JwtSettings>(options =>
{
    // Ensure Secret is trimmed
    options.Secret = options.Secret.Trim();
    
    // Set default if not provided
    if (options.ExpirationMinutes == 0)
    {
        options.ExpirationMinutes = 60;
    }
});

Named Options

You can have multiple configurations of the same type:

appsettings.json JSON
{
  "EmailSettings": {
    "Transactional": {
      "SmtpServer": "smtp.sendgrid.net",
      "SmtpPort": 587
    },
    "Marketing": {
      "SmtpServer": "smtp.mailchimp.com",
      "SmtpPort": 587
    }
  }
}
Program.cs C#
builder.Services.Configure<EmailSettings>("Transactional",
    builder.Configuration.GetSection("EmailSettings:Transactional"));

builder.Services.Configure<EmailSettings>("Marketing",
    builder.Configuration.GetSection("EmailSettings:Marketing"));
EmailService.cs C#
public class EmailService
{
    private readonly EmailSettings _transactionalSettings;
    private readonly EmailSettings _marketingSettings;

    public EmailService(IOptionsSnapshot<EmailSettings> emailOptions)
    {
        _transactionalSettings = emailOptions.Get("Transactional");
        _marketingSettings = emailOptions.Get("Marketing");
    }
}

Complete InvenTrack Example

Options Classes

Options/InventorySettings.cs C#
using System.ComponentModel.DataAnnotations;

namespace InvenTrack.Api.Options;

public class InventorySettings
{
    [Range(1, 1000)]
    public int LowStockThreshold { get; set; } = 10;

    public bool EnableAutoReorder { get; set; }

    [Required]
    [RegularExpression(@"^[A-Z]{3}$", ErrorMessage = "Currency must be 3 uppercase letters")]
    public string DefaultCurrency { get; set; } = "USD";
}
Options/EmailSettings.cs C#
using System.ComponentModel.DataAnnotations;

namespace InvenTrack.Api.Options;

public class EmailSettings
{
    [Required]
    public string SmtpServer { get; set; } = string.Empty;

    [Range(1, 65535)]
    public int SmtpPort { get; set; } = 587;

    [Required]
    [EmailAddress]
    public string SenderEmail { get; set; } = string.Empty;

    [Required]
    public string SenderName { get; set; } = string.Empty;

    public bool EnableSsl { get; set; } = true;
}

Registration in Program.cs

Program.cs C#
// Register all options with validation
builder.Services.AddOptions<JwtSettings>()
    .Bind(builder.Configuration.GetSection("JwtSettings"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Services.AddOptions<InventorySettings>()
    .Bind(builder.Configuration.GetSection("InventorySettings"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Services.AddOptions<EmailSettings>()
    .Bind(builder.Configuration.GetSection("EmailSettings"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Usage in Services

InventoryService.cs C#
public class InventoryService
{
    private readonly InventorySettings _settings;

    public InventoryService(IOptions<InventorySettings> options)
    {
        _settings = options.Value;
    }

    public bool IsLowStock(int quantity)
    {
        return quantity < _settings.LowStockThreshold;
    }
}

Key Takeaways

  • The Options Pattern provides strongly-typed configuration
  • Create POCO classes to represent configuration sections
  • Register with builder.Services.Configure<T>()
  • IOptions<T> - Singleton, no reloading (most common)
  • IOptionsSnapshot<T> - Scoped, reloads per request
  • IOptionsMonitor<T> - Singleton, immediate reloading
  • Use Data Annotations for simple validation
  • Implement IValidateOptions<T> for complex validation
  • ValidateOnStart() catches errors at startup
  • PostConfigure() modifies options after binding
  • Named options for multiple configurations of same type
  • Always validate critical configuration
🎯 Next Steps

You now understand the Options Pattern for strongly-typed, validated configuration! In the final section of Part III, we'll explore Environments—how to manage Development, Staging, and Production environments, and how to configure your application differently for each.