Acessar configurações via _configuration["Chave:Subchave"] é frágil: sem type-safety, sem validação em startup, sem IntelliSense e sem recarga automática quando o valor muda. O sistema IOptions do ASP.NET Core resolve tudo isso. Entender as diferenças entre IOptions, IOptionsSnapshot e IOptionsMonitor é essencial para usar configurações de forma robusta em produção.
Os três tipos de IOptions
| Interface | Ciclo de vida | Recarga em runtime | Uso recomendado |
|---|---|---|---|
IOptions<T> |
Singleton | ❌ Não | Configurações estáticas (JWT, banco) |
IOptionsSnapshot<T> |
Scoped (por request) | ✅ Por request | Configurações que podem mudar (feature flags simples) |
IOptionsMonitor<T> |
Singleton | ✅ Em tempo real | Background services, hot reload real |
Definindo e registrando classes de configuração
// src/Application/Settings/JwtSettings.cs
public class JwtSettings
{
public const string SectionName = "Jwt";
[Required]
[MinLength(32, ErrorMessage = "SecretKey deve ter no mínimo 32 caracteres")]
public string SecretKey { get; set; } = string.Empty;
[Required]
[Url]
public string Issuer { get; set; } = string.Empty;
[Required]
[Url]
public string Audience { get; set; } = string.Empty;
[Range(1, 1440, ErrorMessage = "ExpirationMinutes deve estar entre 1 e 1440")]
public int ExpirationMinutes { get; set; } = 60;
[Range(1, 30, ErrorMessage = "RefreshTokenExpirationDays deve estar entre 1 e 30")]
public int RefreshTokenExpirationDays { get; set; } = 7;
}
// src/Application/Settings/DatabaseSettings.cs
public class DatabaseSettings
{
public const string SectionName = "Database";
[Required]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 500)]
public int MaxPoolSize { get; set; } = 100;
[Range(0, 300)]
public int CommandTimeoutSeconds { get; set; } = 30;
public bool EnableSensitiveDataLogging { get; set; } = false;
public bool EnableRetryOnFailure { get; set; } = true;
}
// src/Application/Settings/EmailSettings.cs
public class EmailSettings
{
public const string SectionName = "Email";
[Required, EmailAddress]
public string From { get; set; } = string.Empty;
[Required]
public string DisplayName { get; set; } = string.Empty;
[Required]
public string SmtpHost { get; set; } = string.Empty;
[Range(1, 65535)]
public int SmtpPort { get; set; } = 587;
public bool UseSsl { get; set; } = true;
[Required]
public string Username { get; set; } = string.Empty;
[Required]
public string Password { get; set; } = string.Empty;
}
Registrando com validação no startup
// Extensão para registro limpo
public static class ConfigurationExtensions
{
public static IServiceCollection AddApplicationSettings(
this IServiceCollection services,
IConfiguration configuration)
{
// ValidateDataAnnotations() — valida via atributos na classe
// ValidateOnStart() — falha no startup se inválido (não em runtime)
services.AddOptions<JwtSettings>()
.Bind(configuration.GetSection(JwtSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<DatabaseSettings>()
.Bind(configuration.GetSection(DatabaseSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<EmailSettings>()
.Bind(configuration.GetSection(EmailSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
return services;
}
}
// Program.cs — simples e limpo
builder.Services.AddApplicationSettings(builder.Configuration);
Validação customizada com IValidateOptions
// Validações que DataAnnotations não consegue expressar
public class JwtSettingsValidator : IValidateOptions<JwtSettings>
{
public ValidateOptionsResult Validate(string? name, JwtSettings options)
{
var errors = new List<string>();
// Validação: SecretKey não pode ser o valor de exemplo
if (options.SecretKey.Contains("exemplo") ||
options.SecretKey.Contains("placeholder") ||
options.SecretKey == "sua-chave-aqui")
{
errors.Add("SecretKey parece ser um placeholder. Use um valor seguro gerado aleatoriamente.");
}
// Validação: Issuer e Audience não podem ser iguais em produção
// (depende de regras de negócio)
if (options.Issuer == options.Audience)
{
errors.Add("Issuer e Audience não devem ser idênticos.");
}
// Validação de formato de URL específico
if (!options.Issuer.StartsWith("https://"))
{
errors.Add("Issuer deve usar HTTPS em produção.");
}
return errors.Any()
? ValidateOptionsResult.Fail(errors)
: ValidateOptionsResult.Success;
}
}
// Registrar junto com a configuração
services.AddOptions<JwtSettings>()
.Bind(configuration.GetSection(JwtSettings.SectionName))
.ValidateDataAnnotations()
.Validate<IWebHostEnvironment>((settings, env) =>
{
// Validação condicional por ambiente
if (env.IsProduction() && settings.ExpirationMinutes > 60)
{
return false; // Tokens longos não permitidos em produção
}
return true;
}, "Em produção, tokens não podem ter mais de 60 minutos de expiração.")
.ValidateOnStart();
// Registrar validator customizado
services.AddSingleton<IValidateOptions<JwtSettings>, JwtSettingsValidator>();
IOptionsSnapshot: configuração recarregada por request
// Configuração que pode mudar entre requests (ex: feature flags via appsettings)
// IOptionsSnapshot recria a instância por request com os valores mais recentes
public class FeatureSettings
{
public const string SectionName = "Features";
public bool NewCheckoutFlow { get; set; }
public bool AiRecommendations { get; set; }
public int MaxItemsPerCart { get; set; } = 50;
}
// Serviço Scoped — pode usar IOptionsSnapshot
public class CheckoutService
{
private readonly FeatureSettings _features;
// IOptionsSnapshot.Value é recalculado em cada request
public CheckoutService(IOptionsSnapshot<FeatureSettings> featuresSnapshot)
=> _features = featuresSnapshot.Value;
public async Task ProcessAsync(Cart cart)
{
if (cart.Items.Count > _features.MaxItemsPerCart)
throw new ValidationException($"Máximo de {_features.MaxItemsPerCart} itens por carrinho");
if (_features.NewCheckoutFlow)
await ProcessWithNewFlowAsync(cart);
else
await ProcessWithLegacyFlowAsync(cart);
}
}
// ⚠️ NUNCA injete IOptionsSnapshot em serviços Singleton
// O Scoped não pode ser capturado por Singleton — causará exceção em runtime
IOptionsMonitor: hot reload em background services
// Background service que reage a mudanças de configuração em tempo real
public class EmailWorker : BackgroundService
{
private readonly IOptionsMonitor<EmailSettings> _emailMonitor;
private readonly ILogger<EmailWorker> _logger;
private IDisposable? _changeToken;
public EmailWorker(
IOptionsMonitor<EmailSettings> emailMonitor,
ILogger<EmailWorker> logger)
{
_emailMonitor = emailMonitor;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Registra callback que executa quando a configuração muda
_changeToken = _emailMonitor.OnChange((settings, name) =>
{
_logger.LogInformation(
"Configuração de e-mail atualizada. Novo SMTP: {Host}:{Port}",
settings.SmtpHost, settings.SmtpPort);
// Recriar o cliente SMTP com a nova configuração
RecreateEmailClient(settings);
});
while (!stoppingToken.IsCancellationRequested)
{
// Usa CurrentValue — sempre o valor mais recente
var settings = _emailMonitor.CurrentValue;
await ProcessEmailQueueAsync(settings, stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
public override void Dispose()
{
_changeToken?.Dispose(); // Cancela o listener de mudanças
base.Dispose();
}
}
Named options: múltiplas configurações do mesmo tipo
// Útil para múltiplos provedores de e-mail, múltiplos bancos, etc.
public class SmtpSettings
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 587;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
// appsettings.json
// {
// "Smtp": {
// "Primary": { "Host": "smtp.gmail.com", "Port": 587, ... },
// "Backup": { "Host": "smtp.sendgrid.com", "Port": 465, ... }
// }
// }
// Registrar named options
services.Configure<SmtpSettings>("Primary",
configuration.GetSection("Smtp:Primary"));
services.Configure<SmtpSettings>("Backup",
configuration.GetSection("Smtp:Backup"));
// Usar named options
public class EmailService
{
private readonly SmtpSettings _primary;
private readonly SmtpSettings _backup;
public EmailService(IOptionsMonitor<SmtpSettings> smtpMonitor)
{
_primary = smtpMonitor.Get("Primary");
_backup = smtpMonitor.Get("Backup");
}
public async Task SendAsync(EmailMessage message)
{
try
{
await SendWithSmtpAsync(_primary, message);
}
catch (SmtpException ex)
{
_logger.LogWarning(ex, "SMTP primário falhou, tentando backup");
await SendWithSmtpAsync(_backup, message);
}
}
}
Configuração por ambiente com override
// appsettings.json (base)
{
"Jwt": {
"Issuer": "https://api.neryx.com.br",
"Audience": "https://neryx.com.br",
"ExpirationMinutes": 60,
"RefreshTokenExpirationDays": 7
},
"Database": {
"MaxPoolSize": 100,
"CommandTimeoutSeconds": 30,
"EnableRetryOnFailure": true
}
}
// appsettings.Development.json (sobrescreve apenas o necessário)
{
"Jwt": {
"SecretKey": "dev-key-32-chars-minimum-here!!",
"Issuer": "https://localhost:7001",
"Audience": "https://localhost:3000",
"ExpirationMinutes": 480
},
"Database": {
"EnableSensitiveDataLogging": true,
"CommandTimeoutSeconds": 300
}
}
// appsettings.Production.json (sem segredos — vêm do Key Vault)
{
"Database": {
"MaxPoolSize": 200,
"CommandTimeoutSeconds": 30
}
}
Testando com configurações customizadas
// Testes de integração com IOptions mockado
public class TokenServiceTests
{
private readonly TokenService _service;
public TokenServiceTests()
{
var settings = new JwtSettings
{
SecretKey = new string('x', 32), // Mínimo para os testes
Issuer = "https://test.example.com",
Audience = "https://test.example.com",
ExpirationMinutes = 5
};
// Options.Create é o helper para criar IOptions em testes
_service = new TokenService(Options.Create(settings));
}
[Fact]
public void GenerateToken_ValidClaims_ReturnsJwt()
{
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.Email, "test@example.com")
});
var token = _service.GenerateToken(identity);
token.Should().NotBeNullOrEmpty();
// Verificar claims no token gerado...
}
}
// WebApplicationFactory com configuração customizada
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((ctx, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string>
{
["Jwt:SecretKey"] = new string('k', 32),
["Jwt:Issuer"] = "https://test.example.com",
["Jwt:Audience"] = "https://test.example.com",
["Jwt:ExpirationMinutes"] = "5"
});
});
});
Regra de ouro: qual usar em cada situação
- Configuração de banco, JWT, SMTP →
IOptions<T>(singleton, estável) - Feature flags simples em controllers →
IOptionsSnapshot<T>(por request) - Background services que precisam de hot reload →
IOptionsMonitor<T> - Múltiplos provedores do mesmo tipo → Named options com
IOptionsMonitor<T>.Get("nome") - Validação que o app não pode subir sem →
ValidateOnStart()obrigatório
Configuração mal gerenciada é fonte constante de bugs e incidentes de segurança. Se você quer uma arquitetura .NET mais robusta e manutenível, a Neryx pode ajudar.