We build a real .NET 10 API that negotiates culture per request, localizes validation and domain exceptions, and responds in Spanish or English without knowing which frontend consumes it.
Translating Angular buttons is a frontend responsibility. Translating validation errors, business rules, and HTTP responses is a different concern: it belongs to the API and must work just as well from a mobile app, Postman, or an automated process.
Localization is often implemented entirely in the frontend. The API returns codes, and the browser decides which text should be displayed.
That approach becomes limiting as soon as a second consumer appears: a mobile application, a B2B integration, Postman, an automation, or a client that doesn't use your web interface.
A truly multilingual API should be able to localize its own responses:
Accept-Language: es-ES{
"success": false,
"message": "La validación falló.",
"errors": {
"Email": ["El formato del correo electrónico no es válido."]
}
}The same request can ask for English:
Accept-Language: en-US{
"success": false,
"message": "Validation failed.",
"errors": {
"Email": ["The email format is invalid."]
}
}There is no condition such as if (frontend === "Angular"). The contract uses
a standard HTTP header, so every consumer can select its language.
What we are going to build
I prepared a complete .NET 10 example that builds and runs real HTTP checks:
Download the multilingual ASP.NET Core API project
After extracting it:
chmod +x verify.sh
./verify.shThe script:
- builds the project;
- starts the API at
http://127.0.0.1:5088; - checks validation responses in English;
- repeats the same request in Spanish;
- checks a localized business rule;
- verifies query-string culture selection.
Expected output:
Build succeeded.
0 Warning(s)
0 Error(s)
All multilingual API checks passed.The API exposes two endpoints:
POST /api/users: validates the request, creates a sample user, and triggers a business rule when the email already exists.GET /api/users/culture: shows which culture ASP.NET Core selected.
Final project structure
aspnetcore-api-multidioma-demo/
├── Contracts/
│ ├── ApiError.cs
│ └── CreateUserRequest.cs
├── Controllers/
│ └── UsersController.cs
├── Errors/
│ ├── BusinessRuleException.cs
│ └── GlobalExceptionHandler.cs
├── Resources/
│ ├── SharedResource.en-US.resx
│ └── SharedResource.es-ES.resx
├── MultilingualApiDemo.csproj
├── Program.cs
├── SharedResource.cs
├── appsettings.json
├── verify.sh
└── README.mdThere are no external packages. The demo uses ASP.NET Core, DataAnnotations,
IStringLocalizer, and IExceptionHandler.
The localization flow
The complete path followed by an error is:
flowchart LR
accTitle: API localization flow
accDescr: The consumer sends Accept-Language, ASP.NET Core selects the culture, internal layers produce a stable key, and the localizer builds the response.
Client["HTTP client"] -->|"Accept-Language: es-ES"| Localization["RequestLocalizationMiddleware"]
Localization --> Culture["CurrentCulture · CurrentUICulture"]
Culture --> Validation["Validation · domain · application"]
Validation -->|"Validation.Email.Invalid"| Keys["Stable key"]
Keys --> Localizer["IStringLocalizer"]
Localizer --> Resources["SharedResource.es-ES.resx"]
Resources --> Response["Localized JSON response"]

All editorial copy in the animation is English. It shows two passes through the same pipeline: first en-US → Hello, then es-ES → Hola. Only culture, resource, and message change.
The important separation is:
- business rules produce stable keys;
- the HTTP boundary selects the translation for the current request;
- the frontend only requests a culture.
1. Register localization
Register the resources:
builder.Services.AddLocalization(
options => options.ResourcesPath = "Resources");Then configure the supported cultures and selection mechanisms:
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
CultureInfo[] supportedCultures =
[
new("en-US"),
new("es-ES")
];
options.DefaultRequestCulture = new RequestCulture("en-US");
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
options.RequestCultureProviders =
[
new AcceptLanguageHeaderRequestCultureProvider(),
new QueryStringRequestCultureProvider(),
new CookieRequestCultureProvider()
];
});Provider order matters because the first successful provider wins.
This example prioritizes:
Accept-Language;?culture=es-ES;- culture cookie;
- default culture.
Activate localization before MVC and exception handling:
app.UseRequestLocalization();
app.UseExceptionHandler();
app.MapControllers();If the middleware runs too late, the localizer uses the wrong culture.
2. Use keys instead of translated text
Validation rules should not contain translations:
[Required(ErrorMessage = "Validation.Name.Required")]
string? NameThe key remains stable across cultures.
Resources/SharedResource.en-US.resx:
<data name="Validation.Name.Required" xml:space="preserve">
<value>Name is required.</value>
</data>Resources/SharedResource.es-ES.resx:
<data name="Validation.Name.Required" xml:space="preserve">
<value>El nombre es obligatorio.</value>
</data>The shared marker can be an empty class:
public sealed class SharedResource;It is consumed through:
IStringLocalizer<SharedResource> localizer
string message = localizer["Validation.Name.Required"];3. Localize automatic [ApiController] validation
This is an easy detail to miss.
Even with RequestLocalizationMiddleware, automatic model-binding errors can
still return the default English ProblemDetails.
The project therefore configures InvalidModelStateResponseFactory:
builder.Services
.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
IStringLocalizer<SharedResource> localizer =
context.HttpContext.RequestServices
.GetRequiredService<IStringLocalizer<SharedResource>>();
Dictionary<string, string[]> errors = context.ModelState
.Where(entry => entry.Value?.Errors.Count > 0)
.ToDictionary(
entry => entry.Key,
entry => entry.Value!.Errors
.Select(error => localizer[error.ErrorMessage].Value)
.ToArray());
return new BadRequestObjectResult(new
{
success = false,
message = localizer["Validation.Failed"].Value,
errors
});
};
});This also localizes failures that happen before controller execution.
4. Localize domain exceptions
Business rules don't need to know the active language:
throw new BusinessRuleException(
"User.Email.AlreadyExists",
StatusCodes.Status409Conflict);The global handler resolves the key after request culture has been selected:
public sealed class GlobalExceptionHandler(
IStringLocalizer<SharedResource> localizer,
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken cancellationToken)
{
BusinessRuleException businessException =
(BusinessRuleException)exception;
context.Response.StatusCode = businessException.StatusCode;
await context.Response.WriteAsJsonAsync(new
{
success = false,
message = localizer[businessException.MessageKey].Value
}, cancellationToken);
return true;
}
}Domain and application layers stay independent from HTTP, Angular, and .resx
files.
5. Test without a frontend
Spanish validation:
curl -X POST http://127.0.0.1:5088/api/users \
-H 'Content-Type: application/json' \
-H 'Accept-Language: es-ES' \
-d '{"email":"invalid","password":"short"}'English validation:
curl -X POST http://127.0.0.1:5088/api/users \
-H 'Content-Type: application/json' \
-H 'Accept-Language: en-US' \
-d '{"email":"invalid","password":"short"}'Culture can also be selected through the query string:
curl 'http://127.0.0.1:5088/api/users/culture?culture=es-ES'{
"culture": "es-ES",
"uiCulture": "es-ES"
}Three failures found while testing the pattern in a real API
The central implementation worked: Accept-Language selected the culture,
validators produced keys, and the global handler translated each message.
Black-box tests still exposed three gaps that a superficial review would have
missed.
Exact culture codes
The API supported es-ES and en-US, while one consumer sent es and en.
The live test showed:
Accept-Language: es-ESreturned Spanish;Accept-Language: esfell back to the default language.
Normalize culture codes in the client or explicitly support neutral cultures.
Don't advertise languages without resources
A configuration file can list many cultures, but that doesn't mean their translations exist.
A configuration can list French, Portuguese, or Japanese while actual resources exist only for English and Spanish. A language is not supported until its files, complete key set, and tests exist.
Test MVC's automatic validation path
FluentValidation failures were correctly localized. However, a request with
missing properties was rejected earlier by [ApiController], which still
returned the default English response.
The downloadable example covers that path with
InvalidModelStateResponseFactory.
Production considerations
Keep a stable error code
Consumers should never make decisions by comparing a translated sentence. A production response can expose both values:
{
"success": false,
"code": "user.email.already_exists",
"message": "A user with this email already exists."
}code remains stable for machines; message changes with culture.

The animation captures the practical rule: consumer logic may depend on code,
but it should never compare the localized text in message.
Do not localize internal logs
Technical logs, exception names, and metrics should keep a consistent operational language. Localize only the surface returned to consumers.
Define missing-key behavior
During development, a missing translation should be treated as a quality failure. Production can fall back to the default language, but it should also emit a metric or warning.
Culture also changes dates and numbers
CurrentCulture controls date, decimal, and currency formatting.
CurrentUICulture selects resources. They often match, but a user may want
English text with another region's formats. Make that decision explicit.
Document the contract
Add Accept-Language to OpenAPI and document:
- valid cultures;
- default culture;
- fallback behavior;
- stable error-code format;
- localized versus stable fields.
When I would not translate API messages
I would not localize every response in an internal service-to-service API. Machine-to-machine communication usually needs structured fields, stable codes, and documentation.
I would localize:
- APIs consumed directly by mobile or web applications;
- errors ultimately shown to users;
- form validation;
- B2B portals where each organization selects a language;
- support and self-service responses.
Production checklist
- Use consistent RFC 4646 codes:
es-ES,en-US,es-CO. - Run
UseRequestLocalizationbefore handlers and endpoints. - Put message keys in validators and exceptions, not translated sentences.
- Localize automatic model-binding failures.
- Return a stable JSON error contract.
- Never expose internal exception messages.
- Add tests per culture and per failure path.
- Define missing-translation behavior.
- Document
Accept-Languagein OpenAPI. - Keep an explicit default-culture fallback.
- Return stable error codes alongside translated messages.
- Do not translate logs or internal details.
- Verify resource-key parity across every culture.
Conclusion
A multilingual API doesn't need to know which frontend consumes it.
It only needs to:
- negotiate culture per request;
- represent messages through stable keys;
- resolve those keys at the HTTP boundary;
- test every path capable of producing an error.
The frontend may help by sending Accept-Language, but producing a coherent
localized response remains the API's responsibility.