Options Pattern no .NET 10: Records e validação no startup
O Options Pattern no .NET 10 é a forma recomendada de transformar seções do appsettings.json em objetos tipados, injetados e validados. Configuração é a parte da aplicação que menos recebe atenção e, ao mesmo tempo, a que mais causa incidente em produção.
Pense em uma ConnectionString apontando para o ambiente errado, um timeout em zero ou uma feature flag invertida. Nenhum desses erros aparece no compilador, e nenhum aparece no teste unitário.
Eles surgem no primeiro request, geralmente em produção. Pior ainda, chegam com uma mensagem de erro que não diz nada sobre a configuração.
O C# 14 chegou com o .NET 10, a versão LTS atual, com suporte até novembro de 2028. Juntos, records imutáveis, membros de extensão e o Options Pattern permitem tratar configuração como um tipo de primeira classe.
Neste artigo, vamos construir esse modelo do zero. Mais importante, vamos entender o que realmente acontece por baixo do binder e do container de injeção de dependência.
O problema das classes de configuração mutáveis
O modelo mais comum de configuração no ASP.NET Core é uma classe POCO com propriedades de leitura e escrita:
public class DatabaseSettings
{
public string ConnectionString { get; set; }
public int CommandTimeout { get; set; }
public bool EnableRetry { get; set; }
}
Esse desenho tem três defeitos estruturais. Vamos olhar cada um deles e ver como o Options Pattern no .NET 10 ajuda a resolvê-los.
O primeiro é a mutabilidade. Como o IOptions<T> é registrado como singleton, a instância fica compartilhada por toda a aplicação, e qualquer serviço pode alterar o CommandTimeout de todos os outros.
O segundo é a ausência de validação. Na prática, o binder aceita qualquer valor que consiga converter, então um CommandTimeout de zero vale tanto quanto um de trinta.
O terceiro é a dispersão da regra. Sem um lugar canônico para validar, a checagem acaba replicada em cada serviço que consome a configuração, quando existe.
O resultado é previsível. A aplicação sobe, responde ao health check e falha na primeira operação real.
Por que um record ajuda no Options Pattern no .NET 10
Um record declarado sem parâmetros posicionais e com propriedades init fica imutável após a construção. Por isso, ele combina bem com o Options Pattern no .NET 10:
public record WeatherSettings
{
public string City { get; init; } = string.Empty;
public string TemperatureUnit { get; init; } = string.Empty;
public int RefreshInterval { get; init; }
public bool EnableForecast { get; init; }
}
O acessor init permite atribuir o valor durante a inicialização do objeto. Depois disso, qualquer nova atribuição gera erro de compilação.
Vale registrar que essa imutabilidade é rasa, conforme a própria documentação do C#. Você não troca a referência de uma propriedade, mas o conteúdo de um objeto referenciado, como um array, continua mutável.
Records também trazem igualdade por valor gerada pelo compilador. Assim, duas instâncias com os mesmos valores são iguais, o que ajuda a detectar se uma recarga de configuração realmente mudou algo.
Além disso, records oferecem o with, que produz uma cópia com alterações pontuais sem tocar no original:
var testSettings = settings with { RefreshInterval = 1 };
Isso é valioso em testes. Afinal, você quer variar um único campo sem reconstruir o objeto inteiro.
Como o Options Pattern no .NET 10 cria o objeto
Aqui está o ponto que costuma passar batido. E ele muda a forma como você declara o record.
O Options Pattern no .NET 10 não usa o binder para criar a instância. Primeiro, o OptionsFactory<TOptions> cria o objeto; só depois as ações de configuração preenchem os valores.
A documentação do OptionsFactory.Create é explícita. O método lança MissingMethodException quando TOptions não tem construtor público sem parâmetros ou quando o tipo é abstrato.
A consequência prática é direta. Um record posicional não funciona com AddOptions<T>().Bind(...):
// isso compila, mas quebra em tempo de execução com o Options Pattern
public record WeatherSettings(string City, string TemperatureUnit, int RefreshInterval);
O binder de configuração suporta construtores parametrizados desde o .NET 7. No entanto, esse suporte vale para configuration.Get<T>() e configuration.Bind(), e não para o caminho do IOptions<T>.
Por isso, o record precisa usar o formato de corpo, com propriedades init e o construtor sem parâmetros implícito. Essa é a regra número um do Options Pattern no .NET 10 com records.
Sobre o modificador required
É tentador escrever public required string City { get; init; } e considerar o problema resolvido. Infelizmente, não é bem assim.
O required é uma checagem de compilação. A especificação do C# descreve o modificador como uma exigência de inicializar o membro no local de criação, ou seja, no inicializador de objeto que você escreve.
O OptionsFactory<TOptions>, porém, cria a instância por reflexão, sem inicializador de objeto. Então essa checagem simplesmente não roda.
O tipo compila e a aplicação sobe. Se a seção não existir no appsettings.json, City chega como string vazia.
Em outras palavras, required documenta a intenção e protege quem constrói o objeto manualmente. Ele não substitui validação em tempo de execução.
Os membros de extensão do C# 14
A grande novidade do C# 14 nesse contexto são os blocos extension. Eles complementam o Options Pattern no .NET 10 ao separar dado bruto de valor derivado.
Antes do C# 14, uma extensão era um método estático com this no primeiro parâmetro. Agora, você declara um bloco que agrupa métodos, propriedades, indexadores e operadores para o mesmo receptor:
namespace SmartRecordApi.Configurations;
public static class WeatherSettingsExtensions
{
extension(WeatherSettings settings)
{
public string DisplayName =>
$"{settings.City} ({settings.TemperatureUnit})";
public bool IsAutoRefreshEnabled =>
settings.RefreshInterval > 0;
}
}
O bloco precisa estar em uma classe estática de nível superior e não genérica. Essa restrição é a mesma dos métodos de extensão tradicionais.
O parâmetro settings é o receptor. Por isso, o nome dele fica em escopo para todos os membros de instância do bloco.
Repare que DisplayName agora é uma propriedade de extensão, algo que não existia antes. Com isso, o record guarda só os dados do arquivo de configuração, e tudo que é derivado fica em outro arquivo.
Para se aprofundar na sintaxe, o artigo sobre o novo modelo de extension members do C# 14 detalha cada caso.
Limitações dos membros de extensão
Existem limitações que valem ser conhecidas. Blocos de extensão não aceitam acessores init nem membros protected.
Além disso, o nome de um membro de extensão não pode ser igual ao nome do tipo estendido.
Há também uma consequência importante e pouco comentada. Membros de extensão compilam para métodos estáticos em outra classe, e não para membros reais do tipo estendido.
Na prática, o binder de configuração não enxerga DisplayName. Da mesma forma, serializadores baseados em reflexão, como o System.Text.Json, também não o enxergam.
Ou seja, se você retornar o record direto de um endpoint, a propriedade de extensão não aparece no JSON. Quando precisar do valor no payload, projete explicitamente:
app.MapGet("/api/weather", (IOptions<WeatherSettings> options) =>
{
var settings = options.Value;
return Results.Ok(new
{
settings.City,
settings.TemperatureUnit,
settings.RefreshInterval,
settings.EnableForecast,
DisplayName = settings.DisplayName
});
});
Validação no Options Pattern no .NET 10
Uma abordagem que circula bastante declara um método Validate() que lança exceção. Em seguida, chama esse método por meio de PostConfigure no registro:
// funciona, mas não valida no startup
builder.Services
.AddOptions<WeatherSettings>()
.Bind(builder.Configuration.GetSection("WeatherSettings"))
.PostConfigure(settings => settings.Validate());
O problema está no momento da execução. E ele derruba todo o propósito da validação.
A documentação do Options Pattern explica que a validação roda na primeira criação de uma instância de TOptions. Isso acontece no primeiro acesso a IOptionsSnapshot<TOptions>.Value no pipeline de request, ou na chamada a IOptionsMonitor<TOptions>.Get(string).
O PostConfigure segue o mesmo ciclo, porque também faz parte da construção da instância. Portanto, ele não roda quando a aplicação sobe.
Assim, a aplicação inicia com configuração inválida, passa no health check e falha no primeiro request real. É exatamente o cenário que queríamos evitar.
ValidateOnStart no Options Pattern no .NET 10
A forma correta no Options Pattern no .NET 10 combina Validate com ValidateOnStart:
builder.Services
.AddOptions<WeatherSettings>()
.Bind(builder.Configuration.GetSection("WeatherSettings"))
.Validate(s => !string.IsNullOrWhiteSpace(s.City),
"City is required.")
.Validate(s => s.RefreshInterval > 0,
"RefreshInterval must be greater than zero.")
.Validate(s => s.TemperatureUnit is "Celsius" or "Fahrenheit",
"TemperatureUnit must be Celsius or Fahrenheit.")
.ValidateOnStart();
Existe também o atalho AddOptionsWithValidateOnStart<TOptions>(). Ele já chama ValidateOnStart internamente e evita o esquecimento.
A diferença de comportamento é substancial. Com ValidateOnStart, o processo não sobe, e a mensagem de erro aponta exatamente qual chave está errada.
Note ainda um detalhe de qualidade da mensagem. Um Validate() que lança ArgumentException interrompe na primeira falha, enquanto os delegates encadeados geram uma única OptionsValidationException com todas as falhas.
Afinal, se três chaves estiverem erradas, você quer descobrir as três de uma vez. Ninguém quer descobrir uma por deploy.
Validação em uma classe dedicada
Quando as regras crescem, o Program.cs vira um depósito de lambdas no Options Pattern no .NET 10. Nesse caso, a alternativa é implementar IValidateOptions<TOptions>.
Essa interface move a validação para uma classe própria e devolve um ValidateOptionsResult com todas as falhas:
public class ValidateWeatherSettings : IValidateOptions<WeatherSettings>
{
public ValidateOptionsResult Validate(string? name, WeatherSettings options)
{
var failures = new List<string>();
if (string.IsNullOrWhiteSpace(options.City))
failures.Add("City is required.");
if (options.RefreshInterval <= 0)
failures.Add("RefreshInterval must be greater than zero.");
if (options.TemperatureUnit is not ("Celsius" or "Fahrenheit"))
failures.Add("TemperatureUnit must be Celsius or Fahrenheit.");
// devolve todas as falhas de uma vez
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}
O registro usa TryAddEnumerable, porque podem existir vários validadores para o mesmo tipo de opção:
builder.Services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<WeatherSettings>, ValidateWeatherSettings>());
Se você valida requisições em Minimal APIs, vale comparar com a validação em Minimal APIs no .NET 10, que resolve um problema parecido em outra camada.
Data Annotations e o gerador de código
Para quem usa Data Annotations no Options Pattern no .NET 10, existe um caminho ainda mais econômico. O gerador de código de validação cria a implementação de IValidateOptions a partir dos atributos.
Basta declarar uma classe parcial vazia com [OptionsValidator]. Em seguida, o gerador produz o código no arquivo Validators.g.cs.
O código gerado não usa reflexão e é compatível com AOT. Além disso, ele fica habilitado automaticamente quando o projeto referencia o Microsoft.Extensions.Options na versão 8 ou superior, o que já acontece em qualquer aplicação ASP.NET Core.
Vale lembrar que o ValidateDataAnnotations vem do pacote Microsoft.Extensions.Options.DataAnnotations. Em projetos com o SDK Microsoft.NET.Sdk.Web, o framework compartilhado já referencia esse pacote implicitamente.
Interfaces de consumo do Options Pattern no .NET 10
Com o record pronto e validado, resta decidir como injetá-lo. O Options Pattern no .NET 10 oferece três interfaces, cada uma com custo e comportamento próprios.
O IOptions<TOptions> é singleton e pode ser injetado em qualquer tempo de vida. Por outro lado, ele não relê a configuração depois que a aplicação sobe e não suporta opções nomeadas.
O IOptionsSnapshot<TOptions> é scoped, recalcula o valor a cada request e enxerga alterações do arquivo. Em compensação, ele não pode ir para serviços singleton e tem custo de performance relevante.
O IOptionsMonitor<TOptions> é singleton e expõe CurrentValue. Além disso, suporta notificações de mudança, opções nomeadas e invalidação seletiva pelo IOptionsMonitorCache<TOptions>.
Para configuração que não muda em tempo de execução, que é a maioria dos casos, IOptions<T> é a escolha correta. Também é a mais barata.
Um alerta operacional vale para quem usa IOptionsMonitor do Options Pattern no .NET 10 em container. Sistemas de arquivo do Docker e compartilhamentos de rede nem sempre emitem notificação de mudança de forma confiável.
Nesse caso, a orientação é definir a variável de ambiente DOTNET_USE_POLLING_FILE_WATCHER como 1. Com isso, você aceita um intervalo fixo de quatro segundos entre as verificações.
Aqui os records brilham de novo. Como o valor é imutável, capturar monitor.CurrentValue em uma variável local garante uma leitura consistente durante toda a operação.
Agrupando configurações relacionadas
Configuração real raramente é plana. Por isso, records aninhados dão estrutura sem perder imutabilidade.
O binder resolve a hierarquia pelo delimitador de dois pontos nas chaves:
public record ApiSettings
{
public WeatherSettings Weather { get; init; } = new();
public CacheSettings Cache { get; init; } = new();
}
Vale conhecer as limitações do binder antes de desenhar a hierarquia. Ele ignora propriedades com setters privados, assim como propriedades sem chave correspondente na configuração.
O binder também não popula IReadOnlyList<T> nem IReadOnlyDictionary<TKey, TValue> diretamente. Essas interfaces não expõem os mecanismos necessários para preencher a coleção.
A recomendação oficial é expor um tipo mutável para o binding e uma interface somente leitura para os consumidores. Dessa forma, você mantém o contrato imutável sem brigar com o binder.
A partir do .NET 11, existe ainda o ConfigurationIgnoreAttribute. Ele instrui o binder a não popular determinada propriedade.
Para cenários com AOT ou trimming no Options Pattern no .NET 10, habilite o gerador de binding com <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> no projeto. Assim, a reflexão sai do caminho de binding.
Se você ainda tem dúvidas sobre como as fontes de configuração se combinam, o artigo sobre o ConfigurationManager no ASP.NET cobre essa base.
Boas práticas do Options Pattern no .NET 10
Declare o record no formato de corpo, com propriedades init. Assim, você garante o construtor sem parâmetros que o Options Pattern no .NET 10 exige.
Use required como documentação de intenção para construção manual. Porém, nunca trate esse modificador como substituto de validação.
Coloque a validação em Validate ou IValidateOptions e finalize sempre com ValidateOnStart.
Agregue todas as falhas em um único resultado, em vez de interromper na primeira.
Mantenha no record apenas o que vem do arquivo. Em seguida, mova o que é derivado para propriedades de extensão.
Lembre que binders e serializadores não enxergam membros de extensão. Então projete explicitamente quando precisar deles em um payload.
Prefira IOptions<T> quando não houver recarga. Reserve o IOptionsMonitor<T> para os casos que realmente precisam reagir a mudanças.
Por fim, agrupe configuração relacionada em records aninhados e mantenha a lógica de negócio fora desses tipos.
Conclusão
Um modelo de configuração não deveria apenas guardar dados. Ele deveria impedir que dados inválidos cheguem à aplicação.
Records resolvem a imutabilidade e a igualdade por valor. Já os blocos extension do C# 14 resolvem a separação entre dado bruto e valor derivado.
Mas nenhum dos dois substitui o ValidateOnStart. É ele que transforma um erro silencioso de produção em uma falha ruidosa de startup no Options Pattern no .NET 10.
A troca é excelente. Você paga alguns segundos a mais na subida da aplicação e deixa de descobrir problema de configuração pelo ticket de um cliente.
FAQ
Records posicionais funcionam com o Options Pattern?
Não. O OptionsFactory<TOptions> cria a instância antes do binding e exige construtor público sem parâmetros, lançando MissingMethodException caso contrário.
O modificador required garante que a configuração exista?
Não. O required é verificado na compilação, no local de criação do objeto, e a instância de opções nasce por reflexão.
Qual a diferença entre PostConfigure e Validate?
O PostConfigure ajusta a instância após o binding e o Validate avalia se ela é válida. Ambos rodam no primeiro acesso, por isso o ValidateOnStart antecipa a execução para o startup.
Propriedades de extensão aparecem no JSON de resposta?
Não. Elas compilam para métodos estáticos em outra classe, então serializadores baseados em reflexão não as enxergam.
Posso usar with para alterar configuração em tempo de execução?
O with produz uma nova instância e não altera a que está registrada no container. Por isso, ele é ótimo para testes e cópias pontuais.
Preciso instalar algo para usar ValidateDataAnnotations em uma API ASP.NET Core?
Não, porque o pacote Microsoft.Extensions.Options.DataAnnotations já vem referenciado pelo framework compartilhado em projetos com o SDK Microsoft.NET.Sdk.Web.
Qual interface usar em um serviço singleton que precisa de configuração atualizada?
O IOptionsMonitor<T>, porque é singleton e expõe CurrentValue. O IOptionsSnapshot<T> é scoped e não pode ir para um singleton.