Construimos una API real en .NET 10 que negocia el idioma por request, localiza validaciones y excepciones de dominio, y responde en español o inglés sin conocer el frontend que la consume.
Traducir botones en Angular es una responsabilidad del frontend. Traducir errores de validación, reglas de negocio y respuestas HTTP es una responsabilidad distinta: pertenece a la API y debe funcionar igual desde una aplicación móvil, Postman o un proceso automatizado.
Cuando una aplicación necesita varios idiomas, es común dejar toda la traducción en el frontend. El navegador recibe códigos y decide qué texto mostrar.
Ese enfoque funciona hasta que aparece un segundo consumidor: una aplicación móvil, una integración B2B, Postman, una automatización o un cliente que no usa tu interfaz web.
Una API realmente multidioma debe poder responder por sí misma:
Accept-Language: es-ES{
"success": false,
"message": "La validación falló.",
"errors": {
"Email": ["El formato del correo electrónico no es válido."]
}
}La misma petición puede pedir inglés:
Accept-Language: en-US{
"success": false,
"message": "Validation failed.",
"errors": {
"Email": ["The email format is invalid."]
}
}No hay una condición como if (frontend === "Angular"). El contrato usa un
encabezado HTTP estándar y cualquier consumidor puede elegir el idioma.
Qué vamos a construir
Preparé un ejemplo completo sobre .NET 10 que compila y ejecuta pruebas HTTP reales:
Descargar el proyecto ASP.NET Core multidioma
Después de descomprimirlo:
chmod +x verify.sh
./verify.shEl script:
- compila el proyecto;
- levanta la API en
http://127.0.0.1:5088; - prueba validaciones en inglés;
- repite la misma petición en español;
- prueba una regla de negocio localizada;
- comprueba la selección de cultura mediante query string.
El resultado esperado es:
Build succeeded.
0 Warning(s)
0 Error(s)
All multilingual API checks passed.La API tendrá dos endpoints:
POST /api/users: valida la petición, crea un usuario de ejemplo y produce una regla de negocio cuando el correo ya existe.GET /api/users/culture: permite comprobar qué cultura resolvió ASP.NET Core.
Estructura final del proyecto
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.mdNo hay paquetes externos. Todo el ejemplo usa ASP.NET Core, DataAnnotations,
IStringLocalizer e IExceptionHandler.
El flujo de localización
El recorrido completo de un error es:
flowchart LR
accTitle: Flujo de localización de una API
accDescr: El consumidor envía Accept-Language, ASP.NET Core selecciona la cultura, las capas internas producen una clave estable y el localizador construye la respuesta.
Client["Cliente HTTP"] -->|"Accept-Language: es-ES"| Localization["RequestLocalizationMiddleware"]
Localization --> Culture["CurrentCulture · CurrentUICulture"]
Culture --> Validation["Validación · dominio · aplicación"]
Validation -->|"Validation.Email.Invalid"| Keys["Clave estable"]
Keys --> Localizer["IStringLocalizer"]
Localizer --> Resources["SharedResource.es-ES.resx"]
Resources --> Response["Respuesta JSON localizada"]

La animación usa textos editoriales en inglés y muestra dos recorridos del mismo pipeline: primero en-US → Hello y después es-ES → Hola. Solo cambian la cultura, el recurso y el mensaje.
La separación importante es esta:
- las reglas de negocio producen claves estables;
- la capa HTTP decide qué traducción corresponde al request;
- el frontend únicamente solicita una cultura.
1. Registrar localización
Primero registramos los recursos:
builder.Services.AddLocalization(
options => options.ResourcesPath = "Resources");Luego configuramos las culturas y cómo puede seleccionarlas el consumidor:
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()
];
});El orden importa: el primer proveedor que reconoce una cultura gana.
En este caso la prioridad es:
Accept-Language;?culture=es-ES;- cookie;
- cultura por defecto.
Finalmente activamos el middleware antes de MVC y del manejador de excepciones:
app.UseRequestLocalization();
app.UseExceptionHandler();
app.MapControllers();Si el middleware se registra demasiado tarde, el localizador trabajará con la cultura equivocada.
2. Usar claves en vez de textos
Las validaciones no deberían contener traducciones:
[Required(ErrorMessage = "Validation.Name.Required")]
string? NameLa clave no cambia entre culturas. Lo que cambia es el recurso.
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>El marcador compartido puede ser una clase vacía:
public sealed class SharedResource;Y se consume así:
IStringLocalizer<SharedResource> localizer
string message = localizer["Validation.Name.Required"];3. Localizar los errores automáticos de [ApiController]
Esta es una trampa fácil de pasar por alto.
Aunque hayas configurado RequestLocalizationMiddleware, los errores
automáticos de model binding pueden seguir devolviendo el ProblemDetails
predeterminado en inglés.
Por eso el proyecto configura explícitamente
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
});
};
});Así también se localizan errores que ocurren antes de entrar al controller.
4. Localizar excepciones de dominio
Las reglas de negocio tampoco necesitan conocer el idioma:
throw new BusinessRuleException(
"User.Email.AlreadyExists",
StatusCodes.Status409Conflict);El manejador global recibe la clave y la traduce con la cultura ya resuelta:
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;
}
}De esta forma dominio y aplicación permanecen independientes de HTTP, Angular y
los archivos .resx.
5. Probarlo sin frontend
Validación en español:
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"}'Validación en inglés:
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"}'También puede seleccionarse mediante query string:
curl 'http://127.0.0.1:5088/api/users/culture?culture=es-ES'{
"culture": "es-ES",
"uiCulture": "es-ES"
}Tres fallos que aparecieron al probar el patrón en una API real
La implementación central funcionaba: Accept-Language seleccionaba la cultura,
las validaciones producían claves y el manejador global traducía cada mensaje.
Las pruebas de caja negra, sin embargo, mostraron tres huecos que una revisión
superficial no habría detectado.
Códigos exactos de cultura
La API soportaba es-ES y en-US, mientras que un consumidor enviaba es y
en.
En la prueba real:
Accept-Language: es-ESdevolvió español;Accept-Language: escayó al idioma por defecto.
Conviene normalizar los códigos en el cliente o registrar también culturas neutrales.
No declares idiomas sin recursos
La configuración puede enumerar muchos idiomas, pero eso no significa que sus traducciones existan.
Una configuración puede listar francés, portugués o japonés y aun así tener recursos reales únicamente para inglés y español. Un idioma no está soportado hasta que existen sus archivos, sus claves completas y sus pruebas.
Prueba también el pipeline automático de MVC
Las validaciones de FluentValidation estaban correctamente localizadas, pero una
petición con propiedades ausentes activó primero la respuesta automática de
[ApiController], que todavía apareció en inglés.
El ejemplo descargable ya cubre ese caso mediante
InvalidModelStateResponseFactory.
Consideraciones de producción
Mantén un código de error estable
Los consumidores no deberían tomar decisiones comparando una frase traducida. Una respuesta de producción puede incluir ambos valores:
{
"success": false,
"code": "user.email.already_exists",
"message": "Ya existe un usuario con este correo electrónico."
}code es estable para máquinas; message cambia según la cultura.

La animación resume la regla práctica: la lógica del consumidor puede depender
de code, pero nunca debería comparar el texto localizado de message.
No traduzcas los logs internos
Los logs técnicos, nombres de excepciones y métricas deberían conservar un idioma operativo consistente. Localiza únicamente la superficie que verá el consumidor.
Define el comportamiento cuando falta una clave
En desarrollo conviene detectar una traducción faltante como error de calidad. En producción puede usarse el idioma por defecto, pero debería existir una métrica o warning que permita corregir el recurso.
Cultura también afecta fechas y números
CurrentCulture controla formatos de fechas, decimales y monedas.
CurrentUICulture controla la selección de recursos. A veces deben coincidir;
otras veces un usuario quiere textos en inglés y formatos regionales de otro
país. Diseña esa decisión de forma explícita.
Documenta el contrato
Incluye Accept-Language en OpenAPI y especifica:
- culturas válidas;
- cultura por defecto;
- fallback;
- formato de los códigos de error;
- campos localizados y campos estables.
Cuándo no usaría mensajes traducidos
No localizaría cada respuesta de una API exclusivamente interna entre servicios. Para comunicación máquina a máquina suelen ser suficientes códigos, campos estructurados y documentación.
Sí localizaría:
- APIs consumidas directamente por aplicaciones móviles o web;
- errores que terminan mostrándose al usuario;
- validaciones de formularios;
- portales B2B donde cada organización elige idioma;
- respuestas de soporte o autoservicio.
Checklist para producción
- Usa códigos RFC 4646 consistentes:
es-ES,en-US,es-CO. - Ejecuta
UseRequestLocalizationantes de handlers y endpoints. - Guarda claves en validadores y excepciones, no frases traducidas.
- Traduce errores automáticos de model binding.
- Define una respuesta JSON uniforme.
- No expongas mensajes internos ni excepciones técnicas.
- Agrega una prueba por cultura y por tipo de error.
- Comprueba qué ocurre cuando falta una traducción.
- Documenta
Accept-Languageen OpenAPI. - Mantén el idioma por defecto como fallback explícito.
- Devuelve códigos de error estables además del mensaje traducido.
- No traduzcas logs ni detalles internos.
- Verifica paridad de claves entre todos los archivos de recursos.
Conclusión
Una API multidioma no necesita conocer el framework del consumidor.
Solo necesita:
- negociar la cultura por request;
- representar los mensajes mediante claves;
- resolver esas claves en el borde HTTP;
- probar todos los caminos que pueden producir errores.
El frontend puede ayudar enviando Accept-Language, pero la responsabilidad de
producir una respuesta coherente pertenece a la API.