skip to content
Stylized letter A in teal with pink sparkle accents Khalid Abuhakmeh

Localize Minimal API Validation Messages in .NET 11

/ 8 min read

For years, localized validation messages were an ASP.NET Core MVC thing. Minimal APIs had nothing built in, so you reached for FluentValidation, a custom endpoint filter, or some IStringLocalizer plumbing you wrote yourself. It could be really frustrating.

ASP.NET Core 11 adds built-in localization to the Microsoft.Extensions.Validation package, so you can localize validation error messages and display names for Minimal APIs and Blazor with no extra library and no custom filter. You call two methods, add some resx files, and your 400 Bad Request responses come back in the caller’s language.

Let’s build a working sample, prove it with an integration test harness across four cultures, and look at the behavior you need to know before you ship it.

The sample in this post targets .NET 11 RC1 and was run end-to-end.

How do you localize Minimal API validation messages in .NET 11?

Register two services and add culture-specific resx files; no separate packages required.

builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.AddValidation();

AddValidation turns on automatic validation of supported Minimal API parameters before your handler runs. AddLocalization registers an IStringLocalizerFactory, and that single registration is what flips localization on inside the validation pipeline. When a validation error is produced, Microsoft.Extensions.Validation looks up the message and the member’s display name against your resources using the request’s CurrentUICulture.

Validation localization used to be MVC-only

ASP.NET Core MVC has had localized DataAnnotations messages for a long time through a system of validation-attribute adapters and IStringLocalizer. That mechanism is tightly coupled to MVC’s model-binding pipeline, so Minimal APIs and Blazor never got to use it. If you wanted translated messages from a Minimal API endpoint, your options were:

Both work, but both are you doing the framework’s job. .NET 11 moves this into Microsoft.Extensions.Validation, a framework-independent layer that Minimal APIs and Blazor share.

Set up the Minimal API

For a sample, I’ve created a complete Program.cs. It registers localization and validation, declares four supported cultures, and exposes one endpoint that accepts a Customer.

using System.Globalization;
using Microsoft.AspNetCore.Localization;
using MinimalApiLocalization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.AddValidation();
var supportedCultures = new[] { "en-US", "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();
app.UseRequestLocalization();
app.MapPost("/customers", (Customer customer) => Results.Ok(customer));
app.Run();
public partial class Program;

The model lives in its own .cs file and carries standard data annotations. The [ValidatableType] attribute marks it for the validation source generator.

using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Validation;
namespace MinimalApiLocalization;
[ValidatableType]
public class Customer
{
[Display(Name = "CustomerName")]
[Required(ErrorMessage = "NameRequired")]
[StringLength(50, MinimumLength = 2, ErrorMessage = "NameLength")]
public string? Name { get; set; }
[Required]
[EmailAddress]
public string? Email { get; set; }
[Range(18, 120)]
public int Age { get; set; }
}

Two things to notice. First, ErrorMessage = "NameRequired" is not literal text; it is a resource key. Second, Email and Age have no ErrorMessage, so they rely on convention-based keys, which I cover next.

How validation keys map to resource entries

When ErrorMessage is set, its value is the lookup key. When it is not set, Microsoft.Extensions.Validation tries conventional keys from most specific to least specific and uses the first one that resolves.

Order Convention key pattern Example for Age’s [Range]
1 {DeclaringType}_{MemberName}_{AttributeType}_Error Customer_Age_RangeAttribute_Error
2 {DeclaringType}_{AttributeType}_Error Customer_RangeAttribute_Error
3 {AttributeType}_Error RangeAttribute_Error

If no key resolves, the attribute’s built-in English message is used, so a missing resource degrades gracefully. This also means you can translate an attribute across the whole app by defining just RangeAttribute_Error once, without touching every model.

The resource file is resolved per type. With ResourcesPath = "Resources" and a root namespace of MinimalApiLocalization, the framework reads Customer resources from Resources/Customer.<culture>.resx. Here is the French file (Resources/Customer.fr-FR.resx), showing the keys we use:

Key Value
CustomerName nom du client
NameRequired Le champ {0} est obligatoire.
NameLength Le champ {0} doit contenir entre {2} et {1} caractères.
RequiredAttribute_Error Le champ {0} est obligatoire.
EmailAddressAttribute_Error Le champ {0} doit être une adresse e-mail valide.
RangeAttribute_Error Le champ {0} doit être compris entre {1} et {2}.

{0} is the member’s display name. For Name, the [Display(Name = "CustomerName")] attribute makes {0} itself a localized value (nom du client). Email and Age have no [Display], so {0} falls back to the raw member name. If you want translated field names, add [Display] and give it a resource key.

How do you select the culture for each request?

AddLocalization registers the services but does not pick a culture. UseRequestLocalization does that per request, and resource lookup uses CurrentUICulture. The middleware ships with three providers, checked in this order:

Provider How the caller sets culture Example
Query string ?culture= value POST /customers?culture=fr-FR
Cookie .AspNetCore.Culture cookie c=fr-FR|uic=fr-FR
Accept-Language header Standard HTTP content negotiation Accept-Language: fr-FR

A culture that is not in the supported list falls back to the default culture you set with SetDefaultCulture.

Prove it with an integration test harness

Let’s verify the behavior with WebApplicationFactory<Program> and run one theory per culture. The harness drives culture two ways: the Accept-Language header, and the ?culture= query string. We also check the English fallback for an unsupported culture.

public class ValidationLocalizationTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
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." },
{ "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"));
}
[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"));
}
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();
}
}

Exposing Program to the test project takes one line at the bottom of Program.cs (public partial class Program;), which the sample already has. Running the suite, all nine tests pass:

Passed! - Failed: 0, Passed: 9, Skipped: 0, Total: 9

What the responses look like

Posting the same invalid customer (name="", email="not-an-email", age=5) with different cultures returns a standard ValidationProblem payload with localized messages. German:

{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": [
"Das Feld Kundenname ist erforderlich."
],
"Email": [
"Das Feld Email muss eine gültige E-Mail-Adresse sein."
],
"Age": [
"Das Feld Age muss zwischen 18 und 120 liegen."
]
}
}

Japanese:

{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": [
"顧客名 は必須です。"
],
"Email": [
"Email は有効なメールアドレスである必要があります。"
],
"Age": [
"Age は 18 から 120 の間でなければなりません。"
]
}
}

Notice that Name localizes its field label (Kundenname, 顧客名) because of [Display], while Email and Age keep their member names. That is the display-name behavior in action.

Gotchas before you ship

  • The source generator is scoped to one assembly. Microsoft.Extensions.Validation generates metadata only in the assembly that calls AddValidation. If your models live in a class library, add the Microsoft.Extensions.Validation package there and expose an AddValidation extension method the host can call.
  • Models must be in .cs files. A source generator cannot read another generator’s output, so types declared in .razor files are invisible to the validation generator.
  • CurrentUICulture drives lookup, not CurrentCulture. AddLocalization does not set the culture; make sure UseRequestLocalization runs and a provider matches the request.
  • This is Minimal APIs and Blazor only. MVC and Razor Pages keep their existing IStringLocalizer adapter system.
  • .NET 10 vs .NET 11. [ValidatableType] and [SkipValidation] were experimental in .NET 10 (diagnostic ASP0029). In .NET 11 they are stable, so no suppression is needed.

Conclusion

Localized Minimal API validation is finally a first-class, built-in feature in .NET 11. Two service registrations, a set of resx files that follow the standard IStringLocalizer conventions, and a request culture provider are all it takes to return validation errors in the caller’s language. No FluentValidation, no custom endpoint filter, no bespoke localizer. If you localized Minimal API messages the hard way before, this is a good time to delete code.

If you are new to .NET localization in general, start with .NET Localization: Getting Started With Resx Files and the ASP.NET Core Localization Jump Start. For more on selecting cultures from middleware, see Remember ASP.NET Request Culture Using Middleware. Thanks for reading, and let me know how you are using this in your APIs.

Comments

Related Posts