.NET ASP.NET Core Configuração Arquitetura Boas Práticas

IOptions no .NET: configuração fortemente tipada, validação e recarga em tempo real

Guia completo do sistema de configuração do ASP.NET Core: IOptions vs IOptionsSnapshot vs IOptionsMonitor, validação com DataAnnotations e.

N
Neryx Digital Architects
4 de dezembro de 2025
11 min de leitura
200 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

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, SMTPIOptions<T> (singleton, estável)
  • Feature flags simples em controllersIOptionsSnapshot<T> (por request)
  • Background services que precisam de hot reloadIOptionsMonitor<T>
  • Múltiplos provedores do mesmo tipo → Named options com IOptionsMonitor<T>.Get("nome")
  • Validação que o app não pode subir semValidateOnStart() 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.

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.