Localize Minimal API Validation Messages in .NET 11
/ 8 min read
Found this post helpful?
Buy me a coffeeTable of Contents
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:
- Hand-roll an
IEndpointFilterthat validated input and localized messages yourself. I covered the filter approach in ASP.NET Core Minimal API Endpoint Filters. - Bring in FluentValidation and a custom
Validated<T>binder.
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: 9What 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.Validationgenerates metadata only in the assembly that callsAddValidation. If your models live in a class library, add theMicrosoft.Extensions.Validationpackage there and expose anAddValidationextension method the host can call. - Models must be in
.csfiles. A source generator cannot read another generator’s output, so types declared in.razorfiles are invisible to the validation generator. CurrentUICulturedrives lookup, notCurrentCulture.AddLocalizationdoes not set the culture; make sureUseRequestLocalizationruns and a provider matches the request.- This is Minimal APIs and Blazor only. MVC and Razor Pages keep their existing
IStringLocalizeradapter system. - .NET 10 vs .NET 11.
[ValidatableType]and[SkipValidation]were experimental in .NET 10 (diagnosticASP0029). 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.