dotnet-options-pattern について
強く型付けされた設定を扱う.NET開発者向けのOptionsパターンガイドです。各インターフェイスのライフタイムと再読み込みの違いを説明し、検証、名前付きオプション、バックグラウンドサービスの例を示します。
主な機能
- 01強く型付けされた設定クラス
- 02IOptions、IOptionsSnapshot、IOptionsMonitorの選択
- 03データ注釈と起動時検証
- 04名前付きオプションの登録と読み取り
- 05バックグラウンドサービスでのオプション利用
活用シーン
- 01アプリケーション設定を強く型付けして定義
- 02サービスのライフタイムに応じたインターフェイス選択
- 03必須設定を起動時に検証
- 04同じ型の複数設定を名前付きで登録
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ ronnythedev/ dotnet-clean-architecture-skills --skill dotnet-options-patternインストール後、Claude Code で/dotnet-options-patternを入力して読み込まれたか確認します。
詳細
.NET の構成における Options パターン
概要
Options パターンを使用すると、構成のグループに型安全な方法でアクセスできます。
- IOptions - シングルトン。起動時に一度読み込みます
- IOptionsSnapshot - スコープ付き。リクエストごとに再読み込みします
- IOptionsMonitor - シングルトン。変更をリアルタイムで通知します
クイックリファレンス
| インターフェイス | ライフタイム | 再読み込みをサポート | 名前付き Options | 用途 |
|---|---|---|---|---|
IOptions<T> | シングルトン | いいえ | いいえ | 静的な構成 |
IOptionsSnapshot<T> | スコープ付き | はい | はい | リクエストスコープの構成 |
IOptionsMonitor<T> | シングルトン | はい | はい | シングルトンサービス、変更通知 |
Options の構造
/Application/Options/
├── DatabaseOptions.cs
├── JwtOptions.cs
├── CacheOptions.cs
├── EmailOptions.cs
└── FeatureFlagOptions.cs
テンプレート:基本的な Options クラス
// 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;
}
対応する appsettings.json
{
"Database": {
"ConnectionString": "Host=localhost;Database=mydb;Username=postgres;Password=secret",
"MaxPoolSize": 100,
"MinPoolSize": 5,
"ConnectionTimeout": 30,
"EnablePooling": true,
"EnableQueryLogging": false
}
}
テンプレート:データ注釈による検証付き Options
// 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
}
テンプレート:カスタム検証付き Options
// 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;
}
}
テンプレート:DependencyInjection への登録
// 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;
}
}
テンプレート:IOptions の使用(シングルトンサービス)
// 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);
}
}
テンプレート:IOptionsSnapshot の使用(スコープ付きサービス)
// 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();
}
}
テンプレート:IOptionsMonitor の使用(変更通知付きシングルトン)
// 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();
}
}
テンプレート:名前付き Options
// 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>();
}
名前付き Options 用 appsettings.json
{
"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}"));
名前付き Options の使用
// 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);
}
}
テンプレート:バックグラウンドサービスでの Options
// 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) │
│ │
└────────────────────────────────────────────────────────────────┘
重要なルール
- SectionName 定数を定義する - 構成パスの唯一の信頼できる情報源にします
- 起動時に検証する -
.ValidateOnStart()を使用して、早期にエラーを検出します - required キーワードを使用する - 必須の構成に使用します(.NET 7+)
- インターフェイスをライフタイムに合わせる - シングルトンには IOptions、スコープ付きには IOptionsSnapshot を使用します
- バックグラウンドサービスには IOptionsMonitor を使用する - これらはシングルトンです
- データ注釈を優先する - カスタムバリデーターよりもシンプルです
- スコープ付きサービスに IOptions を注入しない - 変更が反映されません
- 複数の構成には名前付き Options を使用する - 同じ型に異なる値を設定できます
- Options をドキュメント化する - IntelliSense 用に XML コメントを記述します
- 適切なデフォルト値を設定する - 必須の構成を最小限に抑えます
避けるべきアンチパターン
// ❌ 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- Options を使用したログ構成12-dotnet-jwt-authentication- JWT Options の例15-dotnet-quartz-background-jobs- Options を使用したバックグラウンドサービス01-dotnet-clean-architecture- アプリケーション層への配置
コメント
Markdown 対応