← Volver al blogCómo hacer una API multidioma en ASP.NET Core sin depender del frontend
dotnetaspnet-coreapii18nlocalization

Cómo hacer una API multidioma en ASP.NET Core sin depender del frontend

Construye una API .NET 10 que negocia el idioma por request y localiza validaciones y excepciones con Accept-Language, IStringLocalizer y recursos .resx.

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.sh

El script:

  1. compila el proyecto;
  2. levanta la API en http://127.0.0.1:5088;
  3. prueba validaciones en inglés;
  4. repite la misma petición en español;
  5. prueba una regla de negocio localizada;
  6. 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.md

No 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"]

Flujo animado de localización de una API

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:

  1. Accept-Language;
  2. ?culture=es-ES;
  3. cookie;
  4. 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? Name

La 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-ES devolvió español;
  • Accept-Language: es cayó 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.

El código de error permanece estable mientras el mensaje cambia de idioma

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 UseRequestLocalization antes 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-Language en 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:

  1. negociar la cultura por request;
  2. representar los mensajes mediante claves;
  3. resolver esas claves en el borde HTTP;
  4. 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.

Fuentes

Comentarios

Cargando comentarios…