A maior e melhor Black Friday que já fizemos! Descontos a partir de 50% em até 12x sem juros - Garanta seu Desconto

ValidateOnStart no .NET: como validar IOptions no startup

ValidateOnStart no .NET: como validar IOptions no startup

O ValidateOnStart é o método do padrão de options que obriga o .NET a validar as configurações da aplicação no momento em que ela inicia, e não na primeira vez que alguém lê esses valores. Com ele, uma configuração ausente ou fora da faixa derruba o processo logo na subida, antes que qualquer usuário receba um erro.

Este artigo mostra, passo a passo, como ligar uma seção do appsettings.json a uma classe fortemente tipada, como validar essa classe com Data Annotations e por que a validação sozinha não basta. Além disso, você verá as alternativas para regras mais complexas e os limites que vale conhecer antes de levar a solução para produção.

O que é o ValidateOnStart e qual problema ele resolve

Toda aplicação depende de valores externos ao código: URLs de serviços, nomes de usuário, tempos limite, chaves de API. Por isso, o .NET oferece o padrão de options, que transforma grupos de configurações em classes C# injetadas pelo container de dependências.

O problema aparece quando um desses valores falta no ambiente de destino. Em desenvolvimento, o appsettings.json costuma ter tudo. Em produção, porém, os valores vêm de variáveis de ambiente, do Azure Key Vault ou de outro provedor, e esquecer uma chave é comum.

Sem nenhuma validação, a aplicação sobe normalmente e trabalha com um valor nulo ou zerado. Com validação, mas sem o ValidateOnStart, o erro só aparece quando a primeira requisição tenta ler a configuração, ou seja, quem descobre o problema é o usuário.

Portanto, o ValidateOnStart resolve um problema de momento. Ele antecipa a validação para a inicialização do host, de modo que uma configuração inválida impede a aplicação de subir e o erro aparece no log de deploy, não na tela do cliente.

Como o padrão de options funciona no .NET

O padrão de options usa classes para dar acesso fortemente tipado a grupos de configurações relacionadas. Em vez de espalhar chamadas como configuration["Demo:Timeout"] pelo código, você declara uma classe e pede ao container uma instância de IOptions<T>.

Além disso, o ValidateOnStart atua sobre esse padrão. Esse modelo segue dois princípios importantes. Primeiro, o encapsulamento, porque cada classe depende apenas das configurações que usa; em seguida, a separação de responsabilidades, já que as configurações de partes diferentes da aplicação não ficam acopladas entre si.

Para o exemplo deste artigo, considere uma Minimal API criada com o template padrão do .NET 10, a versão LTS atual. O template traz OpenAPI e o exemplo de previsão do tempo, mas nada disso será necessário aqui.

A seção no appsettings.json

O primeiro passo é agrupar as configurações relacionadas em uma seção própria. Neste caso, a seção Demo reúne a URL do site, o usuário administrador e o tempo limite em segundos:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Demo": {
    "SiteUrl": "https://example.com",
    "AdminUser": "admin",
    "Timeout": 30
  }
}

Vale lembrar que o appsettings.json é só uma das fontes possíveis. Na prática, User Secrets, variáveis de ambiente e cofres de segredos podem sobrescrever esses valores, e é justamente nessa troca de fonte que uma chave costuma se perder.

A classe de options fortemente tipada

Em seguida, crie a classe que representa a seção. Ela precisa ter propriedades públicas de leitura e escrita com os mesmos nomes das chaves do JSON:

using System.ComponentModel.DataAnnotations;

namespace OptionsValidationDemo;

public sealed class DemoOptions
{
    // Nome da seção no appsettings.json, definido em um único lugar
    public const string SectionName = "Demo";

    [Required, Url]
    public string? SiteUrl { get; set; }

    [Required]
    public string? AdminUser { get; set; }

    [Required, Range(1, 90)]
    public int? Timeout { get; set; }
}

A constante SectionName evita repetir a string "Demo" no Program.cs. Assim, se o nome da seção mudar, a alteração acontece em um único ponto, dentro da própria classe que conhece aquela seção.

Repare também que todas as propriedades aceitam nulo. Essa escolha tem um motivo prático, que fica claro na próxima seção.

Até aqui, a classe apenas descreve as regras. Quem decide quando essas regras rodam é o registro no container, e é nesse ponto que o ValidateOnStart entra mais adiante.

Validando as configurações com Data Annotations

Os atributos do namespace System.ComponentModel.DataAnnotations descrevem as regras de cada propriedade. São os mesmos atributos que a validação de modelos usa, inclusive na validação nativa das Minimal APIs no .NET 10.

No exemplo, SiteUrl é obrigatória e precisa ser uma URL válida, enquanto AdminUser só precisa existir. Já Timeout é obrigatório e deve ficar entre 1 e 90 segundos.

Por que usar tipos anuláveis nas options

Se Timeout fosse um int comum, uma chave ausente resultaria no valor padrão 0. Nesse caso, o atributo [Required] não teria efeito, porque um int nunca é nulo e sempre "tem valor".

Com int?, por outro lado, a ausência da chave deixa a propriedade nula, e o [Required] consegue detectar a falta. Além disso, o [Range] sozinho não acusa valores nulos, pois considera nulo como válido, então a combinação dos dois atributos é o que garante presença e faixa ao mesmo tempo.

O mesmo raciocínio vale para as strings. Como elas são anuláveis por natureza, declarar string? deixa explícito para o compilador e para quem lê o código que o valor pode não chegar da configuração.

Registrando as options no container

Com a classe pronta, registre as options no Program.cs usando o OptionsBuilder. O método AddOptions<T> devolve esse builder, que permite encadear a ligação com a configuração e as validações:

using Microsoft.Extensions.Options;
using OptionsValidationDemo;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddOptions<DemoOptions>()
    .Bind(builder.Configuration.GetSection(DemoOptions.SectionName))
    .ValidateDataAnnotations();

var app = builder.Build();

app.UseHttpsRedirection();

// Devolve as configurações carregadas para conferência
app.MapGet("/demo", (IOptions<DemoOptions> options) => options.Value);

app.Run();

O ValidateDataAnnotations vem do pacote Microsoft.Extensions.Options.DataAnnotations. Em projetos web com o SDK Microsoft.NET.Sdk.Web, o shared framework já referencia esse pacote, então nenhuma instalação extra é necessária.

Repare que esse registro ainda não usa o ValidateOnStart. A omissão é proposital, porque o próximo passo mostra o que acontece quando a validação existe, mas roda tarde demais.

O problema da validação sem o ValidateOnStart

Com o código acima, rode a aplicação e acesse /demo. O endpoint devolve os três valores do appsettings.json, e tudo parece funcionar.

Agora remova a chave Timeout da seção e rode de novo. Surpreendentemente, a aplicação sobe sem nenhuma reclamação, porque a validação ainda não aconteceu.

O erro só surge quando você chama /demo. Nesse momento, o acesso a options.Value cria a instância de DemoOptions, executa as Data Annotations e lança uma OptionsValidationException que vira uma resposta 500.

Isso acontece porque a validação roda na primeira vez que uma instância de options é criada. Para IOptions<T>, que é um singleton, isso ocorre no primeiro acesso a Value, e não no app.Run().

Em outras palavras, a aplicação passa no health check, recebe tráfego e só falha quando um usuário toca no recurso que depende daquela configuração. Dependendo da rota, o problema pode ficar escondido por horas ou dias, exatamente o cenário que o ValidateOnStart elimina.

Como ativar o ValidateOnStart no Program.cs

A correção ocupa uma única linha. Basta encadear o ValidateOnStart depois das validações registradas:

builder.Services
    .AddOptions<DemoOptions>()
    .Bind(builder.Configuration.GetSection(DemoOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();

O ValidateOnStart não executa nada no registro. Na verdade, ele cadastra um validador de inicialização que o host chama durante o StartAsync, antes de começar a atender requisições.

Dessa forma, quando o app.Run() inicia o host, todas as options marcadas passam pela validação. Se alguma falhar, o host interrompe a inicialização e o processo termina com a exceção no console.

Comparação entre a validação tardia, em que o erro de configuração aparece na primeira requisição, e o ValidateOnStart, em que a aplicação falha durante a inicialização do host
Sem validação na inicialização, o erro chega ao usuário; com ela, o deploy falha antes de receber tráfego.

O que acontece quando a configuração está inválida

Com o ValidateOnStart ativo, remova novamente a chave Timeout e rode a aplicação. Desta vez, o processo nem chega a escutar a porta, e o console mostra uma mensagem semelhante a esta:

Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'DemoOptions' members: 'Timeout' with the error: 'The Timeout field is required.'.

O mesmo vale para a faixa. Se você trocar Timeout para 100, a inicialização falha com a mensagem de que o campo precisa estar entre 1 e 90.

A mensagem aponta a classe, a propriedade e a regra violada. Por isso, corrigir o problema costuma levar segundos, já que você sabe exatamente qual chave faltou em qual ambiente.

AddOptionsWithValidateOnStart como atalho

Existe ainda um atalho que torna o esquecimento impossível. O método AddOptionsWithValidateOnStart<T> registra as options já com a validação na inicialização:

builder.Services
    .AddOptionsWithValidateOnStart<DemoOptions>()
    .BindConfiguration(DemoOptions.SectionName)
    .ValidateDataAnnotations();

O resultado é equivalente a chamar o ValidateOnStart no fim da cadeia. Além disso, o exemplo usa BindConfiguration, que liga a seção pelo caminho, sem precisar acessar builder.Configuration diretamente.

Por que o ValidateOnStart derruba a aplicação na inicialização

À primeira vista, fazer a aplicação falhar de propósito parece estranho. Contudo, a pergunta certa é outra: por que deixar subir uma aplicação que já está em estado inválido?

Esse comportamento segue o princípio de falhar rápido. Uma falha na inicialização aparece no pipeline de deploy, aciona o rollback e mantém a versão anterior no ar, enquanto uma falha em tempo de execução atinge usuários reais.

Em orquestradores como o Kubernetes ou o Azure Container Apps, isso fica ainda mais claro. Um contêiner que não sobe nunca recebe tráfego, então a nova versão com configuração quebrada simplesmente não substitui a que funciona.

Assim, o ValidateOnStart transforma um bug silencioso em um erro barulhento e barato. O custo de corrigir uma variável de ambiente no deploy é muito menor do que investigar um incidente em produção.

Regras personalizadas com o ValidateOnStart

As Data Annotations cobrem os casos comuns, mas algumas regras dependem de lógica própria. Nesses cenários, o OptionsBuilder aceita um delegate de validação, que também participa do ValidateOnStart:

builder.Services
    .AddOptions<DemoOptions>()
    .BindConfiguration(DemoOptions.SectionName)
    .ValidateDataAnnotations()
    .Validate(
        options => options.SiteUrl?.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ?? false,
        "SiteUrl must use HTTPS.")
    .ValidateOnStart();

O delegate recebe a instância e devolve true quando ela é válida. Se devolver false, a mensagem informada entra na OptionsValidationException.

Validação em uma classe dedicada

Quando as regras crescem, mantê-las no Program.cs atrapalha a leitura. Nesse caso, implemente IValidateOptions<T> e mova a lógica para uma classe própria:

using Microsoft.Extensions.Options;

namespace OptionsValidationDemo;

public sealed class DemoOptionsValidator : IValidateOptions<DemoOptions>
{
    public ValidateOptionsResult Validate(string? name, DemoOptions options)
    {
        // Regra que combina mais de uma propriedade
        if (options.AdminUser == "admin" && options.Timeout > 60)
        {
            return ValidateOptionsResult.Fail(
                "The default admin user cannot use a timeout above 60 seconds.");
        }

        return ValidateOptionsResult.Success;
    }
}

Em seguida, registre o validador como singleton. O ValidateOnStart executa todos os IValidateOptions<T> registrados para aquele tipo, inclusive os de Data Annotations:

builder.Services.AddSingleton<IValidateOptions<DemoOptions>, DemoOptionsValidator>();

Essa abordagem também facilita os testes, porque o validador é uma classe comum. Ou seja, você consegue testar cada regra isoladamente, sem subir o host.

ValidateOnStart com objetos aninhados e validação gerada

Por padrão, as Data Annotations validam apenas as propriedades da própria classe de options. Se DemoOptions tiver uma propriedade complexa, os atributos dentro dela não entram na validação automaticamente.

Para resolver isso, o .NET oferece o atributo [ValidateObjectMembers], que ativa a validação recursiva de objetos aninhados. Já o [ValidateEnumeratedItems] faz o mesmo para cada item de uma coleção:

using Microsoft.Extensions.Options;

namespace OptionsValidationDemo;

public sealed class AppOptions
{
    [ValidateObjectMembers]
    public DemoOptions Demo { get; set; } = new();

    [ValidateEnumeratedItems]
    public List<DemoOptions> Mirrors { get; set; } = [];
}

Esses atributos funcionam em conjunto com o gerador de código de validação. Com [OptionsValidator], o compilador gera a implementação de IValidateOptions<T> a partir das Data Annotations, sem reflexão em tempo de execução:

using Microsoft.Extensions.Options;

namespace OptionsValidationDemo;

[OptionsValidator]
public partial class GeneratedDemoOptionsValidator : IValidateOptions<DemoOptions>
{
}

Na prática, a validação gerada ganha desempenho e se dá melhor com trimming e Native AOT. Como o ValidateOnStart apenas dispara os validadores registrados, ele funciona da mesma forma com a versão gerada.

Cuidados e limites do ValidateOnStart

O ValidateOnStart garante a validade das options no momento da inicialização, mas não depois dela. Vale conhecer alguns pontos antes de considerar o assunto encerrado.

Primeiro, a recarga de configuração. Com IOptionsMonitor<T> ou IOptionsSnapshot<T>, a validação roda de novo sempre que as options são recarregadas, e um valor inválido gravado depois da subida lança exceção no acesso, não na inicialização.

Em segundo lugar, cada chamada vale para o tipo e o nome registrados. Se você usa options nomeadas, marque cada registro com o ValidateOnStart, porque a validação de um nome não cobre os demais.

Além disso, a validação só executa as regras que você declarou. Se uma propriedade não tem atributo nem regra personalizada, nenhum mecanismo vai acusar a falta dela, por isso revise as classes de options com o mesmo cuidado que dedica aos modelos de entrada da API.

Por fim, no .NET 11, ainda em fase de release candidate, a validação de options ganha suporte a regras assíncronas. Essas regras rodam apenas no caminho de inicialização, então a documentação recomenda chamar o ValidateOnStart sempre que usar validadores assíncronos.

Conclusão

Validar options é uma prática simples que evita uma classe inteira de incidentes. No entanto, validar sem antecipar o momento da validação apenas muda o lugar onde o erro aparece, e normalmente esse lugar é a tela do usuário.

O ValidateOnStart fecha essa lacuna com uma linha no Program.cs. Com ele, uma configuração ausente, malformada ou fora da faixa impede a aplicação de subir, e o problema aparece no deploy, onde é barato corrigir.

Em resumo, combine três peças: uma classe de options com tipos anuláveis e Data Annotations, o ValidateDataAnnotations para registrar as regras e o ValidateOnStart para executá-las na inicialização. Para regras mais ricas, use delegates, IValidateOptions<T> ou o gerador de validação, sabendo que todos participam do mesmo fluxo.

Se quiser se aprofundar, a documentação oficial do padrão de options no ASP.NET Core detalha cada interface e cada forma de validação.

FAQ

O ValidateOnStart funciona sem o ValidateDataAnnotations?

Funciona, mas só executa os validadores registrados para aquele tipo. Sem ValidateDataAnnotations, delegate ou IValidateOptions<T>, não existe nenhuma regra para verificar.

Preciso instalar algum pacote para usar o ValidateOnStart?

Em projetos ASP.NET Core, não, porque o shared framework já inclui os pacotes de options. Em aplicações de console ou worker sem esse framework, adicione Microsoft.Extensions.Options.DataAnnotations para usar as Data Annotations.

O ValidateOnStart valida as options a cada requisição?

Não. Ele valida uma vez durante a inicialização do host, e as recargas posteriores com IOptionsMonitor<T> validam no momento em que os novos valores são lidos.

Qual a diferença entre ValidateOnStart e AddOptionsWithValidateOnStart?

O resultado é o mesmo. O AddOptionsWithValidateOnStart<T> apenas registra as options já com a validação na inicialização, o que evita esquecer a chamada no fim da cadeia.

Por que usar int? em vez de int em uma propriedade obrigatória?

Porque um int ausente vira 0 e o [Required] nunca acusa a falta. Com int?, a chave ausente deixa a propriedade nula e a validação detecta o problema.