Localização de validação em Minimal APIs no .NET 11

A localização de validação chegou de forma nativa às Minimal APIs no .NET 11 e muda a forma como tratamos mensagens de erro em APIs multilíngues. Agora o framework devolve as mensagens do 400 Bad Request no idioma de quem fez a requisição, sem nenhuma biblioteca extra.
Neste artigo, vamos montar uma API completa, entender como as chaves de recurso funcionam e comprovar o comportamento com testes de integração. Além disso, veremos os cuidados necessários antes de levar o recurso para produção.
O que é a localização de validação no .NET 11
A localização de validação é a capacidade de traduzir mensagens de erro e nomes de campos de acordo com a cultura da requisição. No .NET 11, essa funcionalidade passou a fazer parte do pacote Microsoft.Extensions.Validation.
Esse pacote é a camada de validação que Minimal APIs e Blazor compartilham. Por isso, as mesmas regras de tradução valem nos dois cenários, tanto em um endpoint quanto em um formulário.
Na prática, bastam duas chamadas no Program.cs para ativar tudo:
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.AddValidation();
O método AddValidation liga a validação automática dos parâmetros antes que o handler execute. Já o AddLocalization registra um IStringLocalizerFactory, e justamente essa presença ativa a localização de validação dentro do pipeline.
Quando um erro surge, o pacote procura a mensagem e o nome de exibição do membro nos seus recursos. Para isso, ele usa a CurrentUICulture da requisição atual.
Não existe pacote separado nem chamada extra de opt in. Em outras palavras, se a fábrica de localizadores está no container, a tradução acontece automaticamente.
Como era a validação antes do .NET 11
Durante muitos anos, mensagens de validação traduzidas eram exclusividade do ASP.NET Core MVC. O MVC usa adaptadores de atributos e IStringLocalizer, mas esse mecanismo depende diretamente do model binding dele.
Como consequência, Minimal APIs e Blazor nunca aproveitaram essa infraestrutura. Quem precisava de mensagens traduzidas em um endpoint mínimo tinha basicamente dois caminhos:
- Escrever um
IEndpointFilterpróprio que validasse a entrada e traduzisse as mensagens manualmente. - Adotar o FluentValidation junto com um binder customizado para os modelos.
As duas abordagens funcionam, mas em ambas você assume um trabalho que deveria pertencer ao framework. Além disso, cada time acabava criando a própria convenção de chaves e arquivos.
O .NET 10 já havia trazido a validação nativa com Data Annotations para Minimal APIs, porém sem suporte a tradução. Com o .NET 11, a localização de validação finalmente entra na caixa.
Assim, o mesmo modelo anotado gera mensagens em português, inglês ou japonês sem código adicional. Essa é a principal mudança que o pacote traz para quem desenvolve APIs.
Configurando a localização de validação na Minimal API
Para demonstrar o recurso, vamos montar uma API com um único endpoint que recebe um cliente. O exemplo tem como alvo o .NET 11 e segue a estrutura do artigo original de Khalid Abuhakmeh, com o acréscimo da cultura pt-BR.
Registrando os serviços e as culturas
O Program.cs abaixo registra localização e validação, declara as culturas suportadas e expõe o endpoint /customers:
using System.Globalization;
using Microsoft.AspNetCore.Localization;
using MinimalApiLocalization;
var builder = WebApplication.CreateBuilder(args);
// Registra a localização e a validação
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.AddValidation();
// Culturas aceitas pela API
var supportedCultures = new[] { "en-US", "pt-BR", "de-DE", "fr-FR", "ja-JP" };
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
options.SetDefaultCulture("en-US");
options.AddSupportedCultures(supportedCultures);
options.AddSupportedUICultures(supportedCultures);
});
var app = builder.Build();
// Define a cultura de cada requisição
app.UseRequestLocalization();
app.MapPost("/customers", (Customer customer) => Results.Ok(customer));
app.Run();
// Expõe a classe Program para os testes de integração
public partial class Program;
Repare na última linha, public partial class Program;. Ela expõe a classe Program para o projeto de testes, algo que usaremos mais adiante.
Além disso, a propriedade ResourcesPath indica ao framework em qual pasta procurar os arquivos .resx. Dessa forma, você mantém todas as traduções organizadas em um único diretório.
Criando o modelo com Data Annotations
O modelo fica em um arquivo .cs próprio e usa as anotações padrão do System.ComponentModel.DataAnnotations. Por sua vez, o atributo [ValidatableType] marca a classe para o gerador de código da validação.
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Validation;
namespace MinimalApiLocalization;
[ValidatableType]
public class Customer
{
// "CustomerName" é a chave do nome de exibição
[Display(Name = "CustomerName")]
// "NameRequired" e "NameLength" são chaves de recurso, não textos
[Required(ErrorMessage = "NameRequired")]
[StringLength(50, MinimumLength = 2, ErrorMessage = "NameLength")]
public string? Name { get; set; }
// Sem ErrorMessage: usa as chaves por convenção
[Required]
[EmailAddress]
public string? Email { get; set; }
[Range(18, 120)]
public int Age { get; set; }
}
Dois detalhes merecem atenção aqui. Primeiro, ErrorMessage = "NameRequired" não representa um texto literal, mas sim uma chave de recurso.
Em segundo lugar, as propriedades Email e Age não definem ErrorMessage. Nesse caso, elas dependem das chaves por convenção, que veremos na próxima seção.
O atributo [Display(Name = "CustomerName")] segue a mesma lógica. Ou seja, CustomerName também funciona como chave, e o framework a traduz para compor o nome do campo na mensagem.
Como a localização de validação resolve as chaves
Entender a resolução das chaves é o ponto central para usar bem a localização de validação. Quando ErrorMessage está preenchido, o valor dele vira a chave de busca e tem prioridade sobre qualquer convenção.
Por outro lado, quando o atributo não define ErrorMessage, o pacote tenta chaves convencionais da mais específica para a mais genérica. A primeira que existir no recurso vence.
Convenções de chave para a localização de validação
A tabela abaixo mostra a ordem de busca, usando como exemplo o [Range] da propriedade Age:
| Ordem | Padrão da chave | Exemplo para o [Range] de Age |
|---|---|---|
| 1 | {DeclaringType}_{MemberName}_{AttributeType}_Error |
Customer_Age_RangeAttribute_Error |
| 2 | {DeclaringType}_{AttributeType}_Error |
Customer_RangeAttribute_Error |
| 3 | {AttributeType}_Error |
RangeAttribute_Error |
Se nenhuma chave resolver, o framework usa a mensagem padrão do atributo em inglês. Dessa forma, um recurso ausente degrada com elegância e nunca mostra a chave crua para o usuário.
Essa ordem traz uma vantagem prática enorme. Você pode traduzir a mensagem do [Range] em toda a aplicação definindo RangeAttribute_Error uma única vez.
Existem também dois detalhes na montagem das chaves. O segmento do membro desaparece em atributos de nível de tipo que não reportam membros, e um tipo de valor anulável contribui com o nome do tipo subjacente.
Onde ficam os arquivos resx
O framework resolve o arquivo de recurso por tipo. Com ResourcesPath = "Resources" e namespace raiz MinimalApiLocalization, os recursos de Customer ficam em Resources/Customer.<culture>.resx.
Veja como fica o arquivo em português, Resources/Customer.pt-BR.resx:
| Chave | Valor |
|---|---|
CustomerName |
nome do cliente |
NameRequired |
O campo {0} é obrigatório. |
NameLength |
O campo {0} deve ter entre {2} e {1} caracteres. |
RequiredAttribute_Error |
O campo {0} é obrigatório. |
EmailAddressAttribute_Error |
O campo {0} deve ser um endereço de e-mail válido. |
RangeAttribute_Error |
O campo {0} deve estar entre {1} e {2}. |
O marcador {0} recebe o nome de exibição do membro. Para Name, o [Display] faz com que {0} também chegue traduzido, resultando em "nome do cliente".
Já Email e Age não têm [Display], então {0} volta para o nome cru da propriedade. Portanto, se você quer nomes de campos traduzidos, adicione [Display] com uma chave de recurso.
Para as demais culturas, basta criar Customer.de-DE.resx, Customer.fr-FR.resx e Customer.ja-JP.resx com as mesmas chaves. Assim, a localização de validação escolhe o arquivo certo conforme a cultura ativa.
Escolhendo a cultura de cada requisição
O AddLocalization registra os serviços, mas não escolhe a cultura. Quem faz isso, requisição por requisição, é o middleware UseRequestLocalization.
Esse detalhe importa porque a busca nos recursos usa a CurrentUICulture, e não a CurrentCulture. O middleware traz três provedores, verificados nesta ordem:
| Provedor | Como o cliente define a cultura | Exemplo |
|---|---|---|
| Query string | Valor de ?culture= |
POST /customers?culture=pt-BR |
| Cookie | Cookie .AspNetCore.Culture |
c=pt-BR\|uic=pt-BR |
Cabeçalho Accept-Language |
Negociação de conteúdo padrão do HTTP | Accept-Language: pt-BR |
Quando a cultura informada não aparece na lista de suportadas, o middleware recorre à cultura padrão definida em SetDefaultCulture. Assim, uma requisição com es-ES recebe as mensagens em inglês no nosso exemplo.
Em APIs consumidas por aplicações web, o cabeçalho Accept-Language costuma ser a escolha mais natural. Afinal, o navegador já envia esse valor automaticamente em cada chamada.
Já em integrações entre sistemas, a query string facilita testes rápidos e depuração. Nesse caso, basta acrescentar ?culture=pt-BR à URL para ver a localização de validação em ação.
Testando a localização de validação com WebApplicationFactory
Confiar apenas em teste manual não basta para um recurso que depende de cultura. Por isso, vamos comprovar a localização de validação com WebApplicationFactory<Program> e uma teoria por cultura.
O teste a seguir controla a cultura de duas formas: pelo cabeçalho Accept-Language e pela query string ?culture=. Ele também verifica o retorno ao inglês quando a API não suporta a cultura recebida.
public class ValidationLocalizationTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
// Cliente inválido em todos os campos
private static readonly object InvalidCustomer =
new { name = "", email = "not-an-email", age = 5 };
public static TheoryData<string, string> ExpectedRequiredMessages => new()
{
{ "en-US", "The customer name field is required." },
{ "pt-BR", "O campo nome do cliente é obrigatório." },
{ "de-DE", "Das Feld Kundenname ist erforderlich." },
{ "fr-FR", "Le champ nom du client est obligatoire." },
{ "ja-JP", "顧客名 は必須です。" },
};
[Theory]
[MemberData(nameof(ExpectedRequiredMessages))]
public async Task Localizes_via_AcceptLanguage_header(string culture, string expected)
{
var client = factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/customers")
{
Content = JsonContent.Create(InvalidCustomer),
};
request.Headers.Add("Accept-Language", culture);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains(expected, await ReadErrorMessages(response, "Name"));
}
[Theory]
[MemberData(nameof(ExpectedRequiredMessages))]
public async Task Localizes_via_query_string(string culture, string expected)
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync($"/customers?culture={culture}", InvalidCustomer);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains(expected, await ReadErrorMessages(response, "Name"));
}
[Fact]
public async Task Unsupported_culture_falls_back_to_default()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/customers?culture=es-ES", InvalidCustomer);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains(
"The customer name field is required.",
await ReadErrorMessages(response, "Name"));
}
// Lê as mensagens de erro de um membro no ProblemDetails
private static async Task<string[]> ReadErrorMessages(HttpResponseMessage response, string member)
{
var problem = await response.Content.ReadFromJsonAsync<JsonElement>();
return problem.GetProperty("errors").GetProperty(member)
.EnumerateArray().Select(e => e.GetString()!).ToArray();
}
}
O método ReadErrorMessages lê o ProblemDetails da resposta e extrai as mensagens de um membro específico. Com isso, cada teste valida exatamente o texto esperado para o campo Name.
No artigo original, o autor executou a suíte com quatro culturas e todos os nove testes passaram. Ao adicionar pt-BR, você ganha dois casos extras sem alterar nada na estrutura dos testes.
Note também que a expectativa em inglês usa "customer name". Isso acontece porque o arquivo Customer.en-US.resx também traduz a chave CustomerName para um rótulo legível.
Como ficam as respostas localizadas
Ao enviar o mesmo cliente inválido com culturas diferentes, a API devolve um ValidationProblem padrão com mensagens traduzidas. Veja a resposta para pt-BR:
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": [
"O campo nome do cliente é obrigatório."
],
"Email": [
"O campo Email deve ser um endereço de e-mail válido."
],
"Age": [
"O campo Age deve estar entre 18 e 120."
]
}
}
Agora a mesma requisição em japonês, conforme o resultado publicado no artigo original:
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": [
"顧客名 は必須です。"
],
"Email": [
"Email は有効なメールアドレスである必要があります。"
],
"Age": [
"Age は 18 から 120 の間でなければなりません。"
]
}
}
Observe que Name traduz o rótulo do campo graças ao [Display]. Enquanto isso, Email e Age mantêm o nome da propriedade, exatamente como a regra de nome de exibição prevê.
Além disso, o campo title do ProblemDetails continua em inglês nos dois casos. A localização de validação atua sobre as mensagens de erro e os nomes de exibição, não sobre os demais campos do payload.
Recursos avançados da localização de validação
Além do cenário básico, o pacote oferece pontos de extensão para casos mais elaborados. Todos eles aparecem na documentação oficial de validação do ASP.NET Core no Microsoft Learn.
Arquivo de recurso compartilhado
Em vez de um .resx por tipo, você pode centralizar todas as mensagens em um único arquivo. Para isso, configure ValidationOptions.LocalizerProvider:
builder.Services.AddValidation(options =>
{
// Todas as chaves vêm do recurso ValidationMessages
options.LocalizerProvider = (_, factory) => factory.Create(typeof(ValidationMessages));
});
O delegate também recebe o tipo validado, então você pode escolher recursos diferentes para modelos diferentes. Essa abordagem ainda resolve o caso de modelos em outro assembly, porque o tipo marcador define tanto o nome quanto o assembly do recurso.
Fontes de tradução além do resx
As traduções não precisam vir de arquivos de recurso. Ao registrar um IStringLocalizerFactory próprio, você troca a origem para um banco de dados, arquivos JSON ou qualquer outro armazenamento.
// A fábrica customizada tem precedência sobre a implementação com resx
builder.Services.AddSingleton<IStringLocalizerFactory, JsonStringLocalizerFactory>();
builder.Services.AddValidation();
A fábrica que você registra tem precedência sobre a implementação padrão baseada em .resx. Assim, você mantém a mesma localização de validação, porém com outra fonte de dados.
Atributos customizados com IValidationMessageFormatter
Um atributo customizado que precisa inserir valores próprios no template implementa IValidationMessageFormatter. O framework chama FormatMessage com a cultura, o template traduzido e o nome de exibição já resolvido.
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Microsoft.Extensions.Validation;
public sealed class DivisibleByAttribute : ValidationAttribute, IValidationMessageFormatter
{
public int Divisor { get; init; }
// {0} recebe o nome de exibição e {1} recebe o divisor
public string FormatMessage(CultureInfo culture, string template, string displayName) =>
string.Format(culture, template, displayName, Divisor);
}
Por fim, atributos que já se traduzem sozinhos, com ErrorMessageResourceType ou [Display(ResourceType = ...)], ficam fora desse pipeline. Eles continuam fazendo a própria busca de recursos, como sempre fizeram.
Quando adotar a localização de validação
Nem toda API precisa de mensagens em vários idiomas. Por exemplo, uma API interna consumida apenas por outros serviços raramente exibe esses textos para pessoas.
Por outro lado, APIs públicas e back ends de aplicativos mobile se beneficiam bastante do recurso. Nesses casos, o front end pode exibir a mensagem recebida diretamente, sem manter um dicionário de traduções duplicado.
O mesmo vale para projetos que combinam Minimal APIs e Blazor. Como as regras são idênticas nos dois lados, a localização de validação garante que o formulário e o endpoint mostrem exatamente o mesmo texto.
Também faz sentido migrar quando você já mantém um filtro de endpoint customizado só para traduzir erros. Afinal, cada linha removida desse filtro reduz a manutenção e o risco de divergência entre telas.
Se as suas regras dependem de consultas ao banco, avalie também a validação assíncrona do .NET 11. Ela faz parte do mesmo pacote, então vale cobrir as mensagens dessas regras com a mesma suíte de testes.
Cuidados antes de ir para produção
A localização de validação é simples de ativar, mas alguns detalhes podem gerar surpresas. Antes de publicar, revise os pontos abaixo.
- O gerador de código vale para um único assembly. O pacote gera metadados apenas no assembly que chama
AddValidation, então modelos em uma class library precisam do pacote e de um método de extensão próprio. - Modelos precisam ficar em arquivos
.cs. Um source generator não lê a saída de outro gerador, por isso tipos declarados em arquivos.razorficam invisíveis. - A busca usa
CurrentUICulture. Garanta que oUseRequestLocalizationexecute e que algum provedor reconheça a requisição. - O recurso atende apenas Minimal APIs e Blazor. MVC e Razor Pages continuam com o sistema de adaptadores baseado em
IStringLocalizer. - Os atributos deixaram de ser experimentais. No .NET 10,
[ValidatableType]e[SkipValidation]geravam o diagnósticoASP0029, mas no .NET 11 nenhuma supressão se faz necessária.
Vale lembrar também que metadados ausentes não geram exceção nem log em tempo de execução. Por isso, um teste de integração como o que criamos funciona como rede de segurança contra regressões silenciosas.
Por fim, mensagens traduzidas não substituem uma API bem protegida. Se você ainda está estruturando a segurança dos endpoints, o artigo sobre autenticação e autorização no ASP.NET Core é um bom ponto de partida.
Conclusão sobre a localização de validação
A localização de validação nativa no .NET 11 elimina uma lacuna antiga das Minimal APIs. Com duas chamadas de serviço, alguns arquivos .resx e um provedor de cultura, a API devolve erros no idioma de quem chama.
Em outras palavras, você não precisa mais de FluentValidation, filtros de endpoint customizados ou localizadores próprios apenas para traduzir mensagens. Se você resolveu isso do jeito difícil no passado, este é um ótimo momento para apagar código.
O próximo passo é aplicar o padrão em uma API real. Comece pelas convenções globais, como RequiredAttribute_Error, e depois refine as mensagens por tipo conforme a necessidade.
FAQ
Preciso instalar algum pacote para traduzir mensagens de validação no .NET 11?
Não, em projetos web o recurso já vem no Microsoft.Extensions.Validation. Basta chamar AddLocalization e AddValidation no Program.cs.
A localização de validação funciona em controllers MVC?
Não, o recurso atende apenas Minimal APIs e Blazor. MVC e Razor Pages continuam usando o sistema de adaptadores com IStringLocalizer.
O que acontece se uma chave não existir no arquivo resx?
O framework usa a mensagem padrão do atributo em inglês. Dessa forma, a chave crua nunca aparece para o usuário.
Como traduzir o nome de um campo na mensagem?
Adicione [Display(Name = "Chave")] na propriedade e crie essa chave no arquivo .resx. O valor traduzido substitui o marcador {0} na mensagem.
Posso guardar as traduções em um banco de dados?
Sim, registre um IStringLocalizerFactory próprio no container. Ele tem precedência sobre a implementação padrão baseada em arquivos .resx.