Help us improve
Share bugs, ideas, or general feedback.
From dotnet-clean-architecture-skills
Implements the .NET Options pattern for strongly-typed configuration with IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T> including validation and reload support.
npx claudepluginhub ronnythedev/dotnet-clean-architecture-skillsHow this skill is triggered — by the user, by Claude, or both
Slash command
/dotnet-clean-architecture-skills:26-dotnet-options-patternThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
The Options pattern provides strongly-typed access to configuration groups:
Provides .NET configuration patterns: Options pattern with validation, IOptions/IOptionsSnapshot/IOptionsMonitor injection, secrets management, environment variables. Use for appsettings, connection strings, config binding.
Configuration patterns using Microsoft.Extensions.Configuration. Covers configuration providers, binding, validation, and best practices for .NET applications. Use when setting up configuration in .NET applications, implementing configuration validation with IValidateOptions, or managing settings across different environments.
Provides .NET backend patterns for APIs, MCP servers, and enterprise apps: clean architecture, DI, EF Core, Dapper, Redis caching, IOptions config, and xUnit testing.
Share bugs, ideas, or general feedback.
The Options pattern provides strongly-typed access to configuration groups:
| Interface | Lifetime | Supports Reload | Named Options | Use Case |
|---|---|---|---|---|
IOptions<T> | Singleton | No | No | Static config |
IOptionsSnapshot<T> | Scoped | Yes | Yes | Request-scoped config |
IOptionsMonitor<T> | Singleton | Yes | Yes | Singleton services, change notifications |
/Application/Options/
├── DatabaseOptions.cs
├── JwtOptions.cs
├── CacheOptions.cs
├── EmailOptions.cs
└── FeatureFlagOptions.cs
// src/{name}.application/Options/DatabaseOptions.cs
namespace {name}.application.options;
/// <summary>
/// Options classes should:
/// - Use the "Options" suffix
/// - Have public getters and setters
/// - Include validation via data annotations or IValidateOptions
/// </summary>
public sealed class DatabaseOptions
{
/// <summary>
/// Configuration section name in appsettings.json
/// </summary>
public const string SectionName = "Database";
/// <summary>
/// Connection string for the primary database
/// </summary>
public required string ConnectionString { get; set; }
/// <summary>
/// Maximum number of connections in the pool
/// </summary>
public int MaxPoolSize { get; set; } = 100;
/// <summary>
/// Minimum number of connections in the pool
/// </summary>
public int MinPoolSize { get; set; } = 5;
/// <summary>
/// Connection timeout in seconds
/// </summary>
public int ConnectionTimeout { get; set; } = 30;
/// <summary>
/// Enable connection pooling
/// </summary>
public bool EnablePooling { get; set; } = true;
/// <summary>
/// Enable query logging for debugging
/// </summary>
public bool EnableQueryLogging { get; set; } = false;
}
{
"Database": {
"ConnectionString": "Host=localhost;Database=mydb;Username=postgres;Password=secret",
"MaxPoolSize": 100,
"MinPoolSize": 5,
"ConnectionTimeout": 30,
"EnablePooling": true,
"EnableQueryLogging": false
}
}
// src/{name}.application/Options/JwtOptions.cs
using System.ComponentModel.DataAnnotations;
namespace {name}.application.options;
public sealed class JwtOptions
{
public const string SectionName = "Jwt";
[Required(ErrorMessage = "JWT Secret is required")]
[MinLength(32, ErrorMessage = "JWT Secret must be at least 32 characters")]
public required string Secret { get; set; }
[Required(ErrorMessage = "JWT Issuer is required")]
public required string Issuer { get; set; }
[Required(ErrorMessage = "JWT Audience is required")]
public required string Audience { get; set; }
[Range(1, 1440, ErrorMessage = "Access token expiration must be between 1 and 1440 minutes")]
public int AccessTokenExpirationMinutes { get; set; } = 15;
[Range(1, 43200, ErrorMessage = "Refresh token expiration must be between 1 and 43200 minutes")]
public int RefreshTokenExpirationMinutes { get; set; } = 10080; // 7 days
}
// src/{name}.application/Options/CacheOptions.cs
namespace {name}.application.options;
public sealed class CacheOptions
{
public const string SectionName = "Cache";
public bool Enabled { get; set; } = true;
public string? RedisConnectionString { get; set; }
public int DefaultExpirationMinutes { get; set; } = 5;
public int SlidingExpirationMinutes { get; set; } = 2;
public string KeyPrefix { get; set; } = string.Empty;
}
// src/{name}.application/Options/Validation/CacheOptionsValidator.cs
using Microsoft.Extensions.Options;
namespace {name}.application.options.validation;
/// <summary>
/// Custom validation using IValidateOptions for complex rules
/// </summary>
public sealed class CacheOptionsValidator : IValidateOptions<CacheOptions>
{
public ValidateOptionsResult Validate(string? name, CacheOptions options)
{
var failures = new List<string>();
if (options.Enabled && string.IsNullOrWhiteSpace(options.RedisConnectionString))
{
failures.Add("RedisConnectionString is required when caching is enabled");
}
if (options.DefaultExpirationMinutes < 1)
{
failures.Add("DefaultExpirationMinutes must be at least 1");
}
if (options.SlidingExpirationMinutes >= options.DefaultExpirationMinutes)
{
failures.Add("SlidingExpirationMinutes must be less than DefaultExpirationMinutes");
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}
// src/{name}.application/DependencyInjection.cs
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using {name}.application.options;
using {name}.application.options.validation;
namespace {name}.application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(
this IServiceCollection services,
IConfiguration configuration)
{
// ═══════════════════════════════════════════════════════════════
// BASIC OPTIONS BINDING
// ═══════════════════════════════════════════════════════════════
services.Configure<DatabaseOptions>(
configuration.GetSection(DatabaseOptions.SectionName));
// ═══════════════════════════════════════════════════════════════
// OPTIONS WITH DATA ANNOTATION VALIDATION
// Validates at startup - fails fast if invalid
// ═══════════════════════════════════════════════════════════════
services.AddOptions<JwtOptions>()
.Bind(configuration.GetSection(JwtOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart(); // Validates immediately at startup
// ═══════════════════════════════════════════════════════════════
// OPTIONS WITH CUSTOM VALIDATION
// ═══════════════════════════════════════════════════════════════
services.AddOptions<CacheOptions>()
.Bind(configuration.GetSection(CacheOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<IValidateOptions<CacheOptions>, CacheOptionsValidator>();
// ═══════════════════════════════════════════════════════════════
// OPTIONS WITH POST-CONFIGURE
// Modify options after binding
// ═══════════════════════════════════════════════════════════════
services.PostConfigure<DatabaseOptions>(options =>
{
// Apply environment-specific modifications
if (string.IsNullOrEmpty(options.ConnectionString))
{
options.ConnectionString = Environment.GetEnvironmentVariable("DATABASE_URL")
?? throw new InvalidOperationException("Database connection string not configured");
}
});
return services;
}
}
// src/{name}.infrastructure/Services/JwtTokenService.cs
using Microsoft.Extensions.Options;
using {name}.application.options;
namespace {name}.infrastructure.services;
/// <summary>
/// Use IOptions<T> for:
/// - Singleton services
/// - Configuration that doesn't change at runtime
/// - Best performance (read once, cached forever)
/// </summary>
public sealed class JwtTokenService : IJwtTokenService
{
private readonly JwtOptions _options;
public JwtTokenService(IOptions<JwtOptions> options)
{
// .Value reads the options once
_options = options.Value;
}
public string GenerateAccessToken(User user)
{
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_options.Secret));
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email.Value),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_options.AccessTokenExpirationMinutes),
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
// src/{name}.application/Features/Email/SendEmail/SendEmailHandler.cs
using Microsoft.Extensions.Options;
using {name}.application.options;
namespace {name}.application.features.email;
/// <summary>
/// Use IOptionsSnapshot<T> for:
/// - Scoped services (per-request in web apps)
/// - Configuration that may change between requests
/// - Named options support
///
/// NOTE: Cannot inject into singleton services!
/// </summary>
public sealed class SendEmailHandler : ICommandHandler<SendEmailCommand, Result>
{
private readonly EmailOptions _options;
private readonly IEmailSender _emailSender;
public SendEmailHandler(
IOptionsSnapshot<EmailOptions> options,
IEmailSender emailSender)
{
// Gets fresh value for each request
_options = options.Value;
_emailSender = emailSender;
}
public async Task<Result> Handle(
SendEmailCommand command,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
{
return Result.Success(); // Email disabled
}
await _emailSender.SendAsync(
to: command.To,
subject: command.Subject,
body: command.Body,
from: _options.FromAddress);
return Result.Success();
}
}
// src/{name}.infrastructure/Services/FeatureFlagService.cs
using Microsoft.Extensions.Options;
using {name}.application.options;
namespace {name}.infrastructure.services;
/// <summary>
/// Use IOptionsMonitor<T> for:
/// - Singleton services that need updated configuration
/// - Real-time configuration changes without restart
/// - Background services and hosted services
/// </summary>
public sealed class FeatureFlagService : IFeatureFlagService, IDisposable
{
private readonly IOptionsMonitor<FeatureFlagOptions> _optionsMonitor;
private readonly ILogger<FeatureFlagService> _logger;
private readonly IDisposable? _changeListener;
public FeatureFlagService(
IOptionsMonitor<FeatureFlagOptions> optionsMonitor,
ILogger<FeatureFlagService> logger)
{
_optionsMonitor = optionsMonitor;
_logger = logger;
// ═══════════════════════════════════════════════════════════════
// CHANGE NOTIFICATION
// Subscribe to configuration changes
// ═══════════════════════════════════════════════════════════════
_changeListener = _optionsMonitor.OnChange((options, name) =>
{
_logger.LogInformation(
"Feature flags updated. DarkMode: {DarkMode}, BetaFeatures: {BetaFeatures}",
options.EnableDarkMode,
options.EnableBetaFeatures);
});
}
public bool IsEnabled(string featureName)
{
// Always gets current value
var options = _optionsMonitor.CurrentValue;
return featureName switch
{
"DarkMode" => options.EnableDarkMode,
"BetaFeatures" => options.EnableBetaFeatures,
"NewCheckout" => options.EnableNewCheckout,
_ => false
};
}
public void Dispose()
{
_changeListener?.Dispose();
}
}
// src/{name}.application/Options/StorageOptions.cs
namespace {name}.application.options;
public sealed class StorageOptions
{
public const string SectionName = "Storage";
// Named option keys
public const string LocalStorage = "Local";
public const string CloudStorage = "Cloud";
public required string BasePath { get; set; }
public int MaxFileSizeMb { get; set; } = 10;
public string[] AllowedExtensions { get; set; } = Array.Empty<string>();
}
{
"Storage": {
"Local": {
"BasePath": "./uploads",
"MaxFileSizeMb": 50,
"AllowedExtensions": [".jpg", ".png", ".pdf"]
},
"Cloud": {
"BasePath": "https://storage.blob.core.windows.net/uploads",
"MaxFileSizeMb": 100,
"AllowedExtensions": [".jpg", ".png", ".pdf", ".zip"]
}
}
}
// src/{name}.infrastructure/DependencyInjection.cs
services.Configure<StorageOptions>(
StorageOptions.LocalStorage,
configuration.GetSection($"{StorageOptions.SectionName}:{StorageOptions.LocalStorage}"));
services.Configure<StorageOptions>(
StorageOptions.CloudStorage,
configuration.GetSection($"{StorageOptions.SectionName}:{StorageOptions.CloudStorage}"));
// src/{name}.infrastructure/Services/FileUploadService.cs
public sealed class FileUploadService : IFileUploadService
{
private readonly StorageOptions _localOptions;
private readonly StorageOptions _cloudOptions;
public FileUploadService(IOptionsSnapshot<StorageOptions> optionsSnapshot)
{
// Access named options
_localOptions = optionsSnapshot.Get(StorageOptions.LocalStorage);
_cloudOptions = optionsSnapshot.Get(StorageOptions.CloudStorage);
}
public async Task<string> UploadAsync(
Stream file,
string fileName,
StorageTarget target)
{
var options = target switch
{
StorageTarget.Local => _localOptions,
StorageTarget.Cloud => _cloudOptions,
_ => throw new ArgumentOutOfRangeException(nameof(target))
};
// Validate file size
if (file.Length > options.MaxFileSizeMb * 1024 * 1024)
{
throw new InvalidOperationException(
$"File exceeds maximum size of {options.MaxFileSizeMb}MB");
}
// Validate extension
var extension = Path.GetExtension(fileName).ToLowerInvariant();
if (!options.AllowedExtensions.Contains(extension))
{
throw new InvalidOperationException(
$"File extension {extension} is not allowed");
}
// Upload to appropriate storage
return await UploadToStorage(file, fileName, options.BasePath);
}
}
// src/{name}.infrastructure/BackgroundJobs/OutboxProcessorJob.cs
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using {name}.application.options;
namespace {name}.infrastructure.backgroundjobs;
/// <summary>
/// Background services are singletons - use IOptionsMonitor<T>
/// to get configuration updates without restart.
/// </summary>
public sealed class OutboxProcessorJob : BackgroundService
{
private readonly IOptionsMonitor<OutboxOptions> _optionsMonitor;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OutboxProcessorJob> _logger;
public OutboxProcessorJob(
IOptionsMonitor<OutboxOptions> optionsMonitor,
IServiceScopeFactory scopeFactory,
ILogger<OutboxProcessorJob> logger)
{
_optionsMonitor = optionsMonitor;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Get current options - may have changed since last iteration
var options = _optionsMonitor.CurrentValue;
if (!options.Enabled)
{
_logger.LogDebug("Outbox processor is disabled");
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
continue;
}
try
{
await ProcessOutboxMessages(options.BatchSize, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing outbox messages");
}
// Interval can change at runtime
await Task.Delay(
TimeSpan.FromSeconds(options.ProcessingIntervalSeconds),
stoppingToken);
}
}
private async Task ProcessOutboxMessages(int batchSize, CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<IOutboxProcessor>();
await processor.ProcessAsync(batchSize, ct);
}
}
┌────────────────────────────────────────────────────────────────┐
│ Which Options Interface? │
├────────────────────────────────────────────────────────────────┤
│ │
│ Is your service a Singleton? │
│ │ │
│ ├── YES ──► Do you need config updates at runtime? │
│ │ │ │
│ │ ├── YES ──► Use IOptionsMonitor<T> │
│ │ │ │
│ │ └── NO ───► Use IOptions<T> │
│ │ │
│ └── NO ──► Use IOptionsSnapshot<T> │
│ (works for Scoped and Transient) │
│ │
└────────────────────────────────────────────────────────────────┘
.ValidateOnStart() to fail fast// ❌ WRONG: Accessing configuration directly
public class OrderService
{
public OrderService(IConfiguration configuration)
{
var connectionString = configuration["Database:ConnectionString"];
}
}
// ✅ CORRECT: Use strongly-typed options
public class OrderService
{
public OrderService(IOptions<DatabaseOptions> options)
{
var connectionString = options.Value.ConnectionString;
}
}
// ❌ WRONG: IOptionsSnapshot in singleton service
public class SingletonService // Registered as Singleton!
{
public SingletonService(IOptionsSnapshot<MyOptions> options) // Will fail at runtime!
{
}
}
// ✅ CORRECT: Use IOptionsMonitor for singletons that need updates
public class SingletonService
{
public SingletonService(IOptionsMonitor<MyOptions> options)
{
var current = options.CurrentValue;
}
}
// ❌ WRONG: Not validating options
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
// ✅ CORRECT: Validate options at startup
services.AddOptions<JwtOptions>()
.Bind(configuration.GetSection("Jwt"))
.ValidateDataAnnotations()
.ValidateOnStart();
// ❌ WRONG: Magic strings for section names
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
services.Configure<JwtOptions>(configuration.GetSection("JWT")); // Inconsistent!
// ✅ CORRECT: Use constant for section name
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
23-dotnet-logging-configuration - Logging configuration using options12-dotnet-jwt-authentication - JWT options example15-dotnet-quartz-background-jobs - Background services with options01-dotnet-clean-architecture - Application layer placement