Skip to content

Internationalization (i18n)

Gryd.IO provides built-in internationalization support using .NET's Microsoft.Extensions.Localization with .resx resource files. All user-facing messages (validation errors, exception details, business messages) are fully translatable.

Supported Languages

CultureLanguageStatus
en-USEnglish (US)Default
pt-BRPortuguese (Brazil)✅ Included
es-ESSpanish (Spain)✅ Included

Quick Setup

1. Register Localization Services

csharp
var builder = WebApplication.CreateBuilder(args);

// Add Gryd.IO localization (cultures + resource files)
builder.Services.AddGrydLocalization();

// Add GrydAuth (automatically registers AddLocalization())
builder.Services.AddGrydAuth(builder.Configuration);

var app = builder.Build();

// Enable request localization middleware (BEFORE MapControllers)
app.UseGrydLocalization();

app.MapControllers();
app.Run();

TIP

If you're using the gryd-api or gryd-minimal templates, localization is already configured for you in Program.cs.

2. Send the Accept-Language Header

The API resolves the user's culture from the HTTP request. There are three ways to specify it, in order of priority:

MethodExamplePriority
Accept-Language headerAccept-Language: pt-BR1st
Query string?culture=pt-BR&ui-culture=pt-BR2nd
Cookie.AspNetCore.Culture=c=pt-BR|uic=pt-BR3rd
bash
# cURL example - Portuguese
curl -H "Accept-Language: pt-BR" https://api.example.com/api/v1/auth/login

# cURL example - Spanish
curl -H "Accept-Language: es-ES" https://api.example.com/api/v1/users

If no culture is specified, en-US is used as the default.


Architecture Overview

┌──────────────────────────────────────────────────────────┐
│                     HTTP Request                         │
│              Accept-Language: pt-BR                      │
└──────────────────────┬───────────────────────────────────┘

┌──────────────────────────────────────────────────────────┐
│          UseGrydLocalization() Middleware                 │
│    Resolves CultureInfo from header/query/cookie         │
└──────────────────────┬───────────────────────────────────┘

┌──────────────────────────────────────────────────────────┐
│            IStringLocalizer<TMessages>                    │
│     Looks up resource key in .resx for current culture   │
└──────────┬───────────────────────────────┬───────────────┘
           │                               │
    ┌──────▼──────┐                 ┌──────▼──────┐
    │  .resx file │                 │  Fallback   │
    │  (pt-BR)    │                 │  (en-US     │
    │             │                 │  constant)  │
    └─────────────┘                 └─────────────┘

Key Concepts

  • Message classes (AuthMessages, ValidationMessages, ExceptionMessages, InfrastructureMessages) hold const string values in English. These constants serve double duty — as the default value and as the resource key.
  • .resx files contain translations keyed by the exact English constant value.
  • IStringLocalizer<T> resolves the translated string at runtime based on the current culture. If no .resx match is found, it falls back to the constant's English value.

Using Localized Messages

In Handlers (MediatR)

Inject IStringLocalizer<AuthMessages> in your command/query handler:

csharp
using Microsoft.Extensions.Localization;
using GrydAuth.Application.Resources.Messages;

public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Result<OrderDto>>
{
    private readonly IStringLocalizer<AuthMessages> _localizer;

    public CreateOrderCommandHandler(IStringLocalizer<AuthMessages> localizer)
    {
        _localizer = localizer;
    }

    public async Task<Result<OrderDto>> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        var user = await _userRepository.GetByIdAsync(request.UserId, ct);
        if (user is null)
            return Result<OrderDto>.Failure(
                _localizer[AuthMessages.Common.UserNotFound]  // ← localized
            );

        // ...
    }
}

In FluentValidation Validators

Inject IStringLocalizer<ValidationMessages> in the validator constructor:

csharp
using FluentValidation;
using Microsoft.Extensions.Localization;
using GrydAuth.Application.Resources.Messages;

public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
    public CreateProductCommandValidator(IStringLocalizer<ValidationMessages> localizer)
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .WithMessage(localizer[ValidationMessages.Common.NameRequired]);

        RuleFor(x => x.Description)
            .MaximumLength(1000)
            .WithMessage(localizer[ValidationMessages.Common.DescriptionMaxLength]);
    }
}

WARNING

Validators are registered via AddValidatorsFromAssembly() with ServiceLifetime.Scoped. The IStringLocalizer<T> is injected automatically by DI — no additional registration is needed.

With Format Placeholders

Some messages contain {0}, {1} placeholders for dynamic values. Pass the arguments directly to the localizer:

csharp
// Message constant:
// public const string FileSizeExceeded = "File size cannot exceed {0} MB";

// Usage with arguments:
_localizer[ValidationMessages.ProfilePicture.FileSizeExceeded, maxSizeMb]
// en-US → "File size cannot exceed 5 MB"
// pt-BR → "O tamanho do arquivo não pode exceder 5 MB"
// es-ES → "El tamaño del archivo no puede exceder 5 MB"

In Exception Handlers

The built-in exception handlers already use IStringLocalizer internally. Your custom exception handlers can follow the same pattern:

csharp
using Microsoft.Extensions.Localization;
using Gryd.API.Messages;

public class MyExceptionHandler : IExceptionHandler
{
    private readonly IStringLocalizer<ExceptionMessages> _localizer;

    public MyExceptionHandler(IStringLocalizer<ExceptionMessages> localizer)
    {
        _localizer = localizer;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, Exception exception, CancellationToken ct)
    {
        var problemDetails = new ProblemDetails
        {
            Title = _localizer[ExceptionMessages.Titles.BadRequest],
            Detail = _localizer[ExceptionMessages.Details.RequiredParameter, "orderId"],
            Status = 400
        };

        // ...
    }
}

Message Classes Reference

Gryd.IO provides 4 centralized message classes. All are non-static classes with nested static classes containing const string fields.

AuthMessages

Authentication, authorization, user management, and multi-tenancy messages.

Namespace: GrydAuth.Application.Resources.Messages

SectionDescriptionExample Constants
Authentication.LoginLogin flow messagesUserNotFound, InvalidCredentials, AccountLocked
Authentication.FirstLoginFirst login / password changePasswordMustBeDifferentFromTemporary
Authentication.TokenJWT token messagesTokenExpired, TokenInvalid
Authentication.RefreshTokenRefresh token messagesRefreshTokenExpired
Authentication.LogoutLogout messagesLogoutSuccess, GlobalLogoutCompleted
Authentication.SwitchTenantTenant switchingTenantSwitchSuccess
Validation.PasswordPassword rulesRequired, MinLength, MaxLength
Validation.UserUser validationIdRequired, EmailRequired
Authorization.RoleRole access controlRoleNotFound, CannotDeleteAdminRole
Authorization.PermissionPermission controlPermissionNotFound
Authorization.AppIdAppId managementAppIdNotFound
MultiTenancyTenant operationsTenantNotFound, UserNotAssignedToTenant
GeoLocationLocation-based securityLatitudeOutOfRange, ImpossibleTravelDetected
CircuitBreakerCircuit breaker statusServiceOpen, ServiceClosed
ErrorCodesLocale-invariant codesTenant.NotFound, User.NotFound

ValidationMessages

FluentValidation messages for input validation.

Namespace: GrydAuth.Application.Resources.Messages

SectionDescriptionExample Constants
CommonShared validationsDescriptionMaxLength
UserUser field validationEmailRequired, EmailInvalid, FirstNameMaxLength
PasswordPassword validationRequired, CurrentRequired, MustBeDifferent
AppIdAppId validationRequired, MaxLength, InvalidFormat
RoleRole validationNameRequired, IdRequired
PermissionPermission validationNameRequired, CategoryMaxLength
TenantTenant validationNameRequired, DomainInvalid
TenantAssignmentAssignment rulesEndDateAfterStart, MetadataMaxLength
ProfilePictureUpload validationInvalidExtension, FileSizeExceeded
PasswordResetReset token validationTokenRequired, TokenInvalidFormat
NetworkNetwork validationIpAddressMaxLength, UserAgentMaxLength
SocialLoginSocial auth validationProviderRequired, AccessTokenRequired

ExceptionMessages

Core exception messages for ProblemDetails HTTP error responses.

Namespace: Gryd.API.Messages

SectionDescriptionExample Constants
TitlesHTTP error titlesBadRequest, NotFound, Conflict
DetailsError detail messagesRequiredParameter, EntityNotFound
ErrorTypesRFC 7807 error type URIs(locale-invariant — not translated)

InfrastructureMessages

Cache and database infrastructure error messages.

Namespace: GrydAuth.Infrastructure.Resources.Messages

SectionDescriptionExample Constants
Cache.TitlesCache error titlesConnectionFailed, Timeout
Cache.DetailsCache error detailsOperationFailed, CircuitBreakerOpen
Database.TitlesDatabase error titlesConnectionFailed, Timeout
Database.DetailsDatabase error detailsOperationFailed, MigrationFailed

Adding a New Language

To add support for a new language (e.g., French fr-FR):

Step 1: Register the Culture

Update GrydCultures.cs:

csharp
public static class GrydCultures
{
    public const string Default = "en-US";
    public static readonly string[] Supported = ["en-US", "pt-BR", "es-ES", "fr-FR"]; 
}

Step 2: Create Resource Files

For each message class, create a new .resx file following the naming convention {ClassName}.{culture}.resx:

src/
├── Core/Gryd.API/Messages/
│   └── ExceptionMessages.fr-FR.resx          ← NEW
├── Modules/Auth/GrydAuth.Application/Resources/Messages/
│   ├── AuthMessages.fr-FR.resx               ← NEW
│   └── ValidationMessages.fr-FR.resx         ← NEW
└── Modules/Auth/GrydAuth.Infrastructure/Resources/Messages/
    └── InfrastructureMessages.fr-FR.resx     ← NEW

Step 3: Populate Translations

Each .resx file uses the English constant value as the key (name) and the translated text as the value:

xml
<?xml version="1.0" encoding="utf-8"?>
<root>
  <!-- Standard .resx header (schema + resheader) omitted for brevity -->

  <data name="User not found" xml:space="preserve">
    <value>Utilisateur introuvable</value>
  </data>

  <data name="Invalid credentials" xml:space="preserve">
    <value>Identifiants invalides</value>
  </data>

  <!-- Format strings: preserve {0}, {1} placeholders -->
  <data name="File size cannot exceed {0} MB" xml:space="preserve">
    <value>La taille du fichier ne peut pas dépasser {0} Mo</value>
  </data>
</root>

Important

The name attribute in the .resx file must exactly match the English constant value from the message class. Any mismatch will cause the localizer to fall back to English silently.

Step 4: Verify

Send a request with the new language header:

bash
curl -H "Accept-Language: fr-FR" https://api.example.com/api/v1/users/invalid-id

Expected response:

json
{
  "title": "Requête incorrecte",
  "detail": "Utilisateur introuvable",
  "status": 400
}

Adding Custom Messages to Your Application

When building your own modules on top of Gryd.IO, follow this pattern to make your messages translatable.

Step 1: Create a Message Class

csharp
namespace MyApp.Resources.Messages;

/// <summary>
/// Centralized messages for the Products module.
/// i18n READY: Constants serve as resource keys for IStringLocalizer.
/// </summary>
public class ProductMessages
{
    public static class Validation
    {
        public const string NameRequired = "Product name is required";
        public const string NameMaxLength = "Product name cannot exceed 200 characters";
        public const string PriceMinValue = "Price must be greater than zero";
        public const string SkuFormat = "SKU must follow the format XXX-0000";
    }

    public static class Business
    {
        public const string NotFound = "Product not found";
        public const string AlreadyExists = "A product with this SKU already exists";
        public const string OutOfStock = "Product is out of stock";
        public const string InsufficientQuantity = "Requested quantity ({0}) exceeds available stock ({1})";
    }
}

Step 2: Create Translation Files

Place .resx files next to the message class:

MyApp/
└── Resources/
    └── Messages/
        ├── ProductMessages.cs
        ├── ProductMessages.pt-BR.resx
        └── ProductMessages.es-ES.resx

Example ProductMessages.pt-BR.resx:

xml
<?xml version="1.0" encoding="utf-8"?>
<root>
  <!-- schema + resheader omitted -->

  <data name="Product name is required" xml:space="preserve">
    <value>Nome do produto é obrigatório</value>
  </data>
  <data name="Product name cannot exceed 200 characters" xml:space="preserve">
    <value>Nome do produto não pode exceder 200 caracteres</value>
  </data>
  <data name="Price must be greater than zero" xml:space="preserve">
    <value>Preço deve ser maior que zero</value>
  </data>
  <data name="SKU must follow the format XXX-0000" xml:space="preserve">
    <value>SKU deve seguir o formato XXX-0000</value>
  </data>
  <data name="Product not found" xml:space="preserve">
    <value>Produto não encontrado</value>
  </data>
  <data name="A product with this SKU already exists" xml:space="preserve">
    <value>Um produto com este SKU já existe</value>
  </data>
  <data name="Product is out of stock" xml:space="preserve">
    <value>Produto sem estoque</value>
  </data>
  <data name="Requested quantity ({0}) exceeds available stock ({1})" xml:space="preserve">
    <value>Quantidade solicitada ({0}) excede o estoque disponível ({1})</value>
  </data>
</root>

Step 3: Use in Your Code

csharp
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Result<ProductDto>>
{
    private readonly IStringLocalizer<ProductMessages> _localizer;

    public CreateProductCommandHandler(IStringLocalizer<ProductMessages> localizer)
    {
        _localizer = localizer;
    }

    public async Task<Result<ProductDto>> Handle(CreateProductCommand request, CancellationToken ct)
    {
        var existing = await _repo.FindBySkuAsync(request.Sku, ct);
        if (existing is not null)
            return Result<ProductDto>.Failure(
                _localizer[ProductMessages.Business.AlreadyExists]
            );

        // With format arguments
        if (request.Quantity > stock.Available)
            return Result<ProductDto>.Failure(
                _localizer[ProductMessages.Business.InsufficientQuantity, 
                    request.Quantity, stock.Available]
            );

        // ...
    }
}
csharp
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
    public CreateProductCommandValidator(IStringLocalizer<ProductMessages> localizer)
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage(localizer[ProductMessages.Validation.NameRequired])
            .MaximumLength(200).WithMessage(localizer[ProductMessages.Validation.NameMaxLength]);

        RuleFor(x => x.Price)
            .GreaterThan(0).WithMessage(localizer[ProductMessages.Validation.PriceMinValue]);

        RuleFor(x => x.Sku)
            .Matches(@"^[A-Z]{3}-\d{4}$")
            .WithMessage(localizer[ProductMessages.Validation.SkuFormat]);
    }
}

TIP

Make sure AddLocalization() is registered in your DI container. If you're using AddGrydLocalization() or AddGrydAuth(), it's already done.


Resource File Structure

Naming Convention

{MessageClassName}.{culture}.resx
FilePurpose
ProductMessages.pt-BR.resxPortuguese translations
ProductMessages.es-ES.resxSpanish translations
ProductMessages.fr-FR.resxFrench translations

No base .resx needed

You do not need a base ProductMessages.resx (without culture suffix). When no matching .resx is found for the requested culture, IStringLocalizer falls back to the constant's English value automatically.

Placement Rules

The .resx files must be placed in the same directory as the message class they belong to. The resource locator uses the class's full namespace to find the files:

✅ CORRECT — .resx next to .cs
Resources/Messages/ProductMessages.cs
Resources/Messages/ProductMessages.pt-BR.resx

❌ WRONG — .resx in a separate Resources folder
Resources/Messages/ProductMessages.cs
Resources/ProductMessages.pt-BR.resx

Format Placeholders

For messages with dynamic values, use numbered placeholders {0}, {1}, {2}:

csharp
// In the message class
public const string InsufficientQuantity = 
    "Requested quantity ({0}) exceeds available stock ({1})";

// In the .resx file — keep the same placeholders
// <data name="Requested quantity ({0}) exceeds available stock ({1})">
//   <value>Quantidade solicitada ({0}) excede o estoque disponível ({1})</value>
// </data>

// Usage
_localizer[ProductMessages.Business.InsufficientQuantity, requestedQty, availableStock]

Format specifiers like {0:F1} or {0:N0} are also supported — preserve them in translations.


ErrorCodes — Locale-Invariant

ErrorCodes are never translated. They are stable, machine-readable identifiers used for programmatic logic (e.g., mapping error responses to HTTP status codes):

csharp
// ✅ ErrorCodes — invariant, used for routing/logic
if (result.ErrorCode == AuthMessages.ErrorCodes.Tenant.NotFound)
    return NotFound(result);

// ❌ NEVER compare against localized messages
if (result.ErrorMessage == "Tenant not found")  // FRAGILE — breaks with i18n!
    return NotFound(result);

DANGER

Never use ErrorMessage for control flow. Use ErrorCode instead. Error messages will change based on the user's language.


Frontend Integration

Axios Interceptor

Set the Accept-Language header globally:

typescript
import axios from 'axios';

const api = axios.create({
  baseURL: 'https://api.example.com',
});

// Set language from user preference or browser
api.defaults.headers.common['Accept-Language'] = navigator.language; // e.g., "pt-BR"

// Or from a user setting
api.interceptors.request.use((config) => {
  config.headers['Accept-Language'] = userStore.language; // "pt-BR" | "es-ES" | "en-US"
  return config;
});

Fetch API

typescript
const response = await fetch('https://api.example.com/api/v1/users', {
  headers: {
    'Accept-Language': 'pt-BR',
    'Authorization': `Bearer ${token}`,
  },
});

Displaying Validation Errors

The API returns validation errors already translated. Your frontend just needs to display them:

json
// POST /api/v1/users with Accept-Language: pt-BR
// Response 400:
{
  "errors": {
    "Email": ["E-mail é obrigatório"],
    "Password": ["Senha é obrigatória"]
  }
}
vue
<!-- Vue.js example -->
<template>
  <form @submit.prevent="submit">
    <div v-for="(messages, field) in errors" :key="field">
      <span class="error" v-for="msg in messages" :key="msg">
        {{ msg }}  <!-- Already translated by the API -->
      </span>
    </div>
  </form>
</template>

Testing

Unit Tests with Mock Localizer

When testing handlers or validators, mock the IStringLocalizer<T> to return the key as the value (passthrough behavior):

csharp
using Microsoft.Extensions.Localization;
using Moq;

// Setup mock that returns the key itself as the localized value
var mockLocalizer = new Mock<IStringLocalizer<ValidationMessages>>();

// Single key lookup — returns the key as the value
mockLocalizer
    .Setup(l => l[It.IsAny<string>()])
    .Returns((string key) => new LocalizedString(key, key));

// Parameterized lookup — formats the key with arguments
mockLocalizer
    .Setup(l => l[It.IsAny<string>(), It.IsAny<object[]>()])
    .Returns((string key, object[] args) => 
        new LocalizedString(key, string.Format(key, args)));

// Use in validator
var validator = new CreateProductCommandValidator(mockLocalizer.Object);
var result = await validator.ValidateAsync(command);

result.Errors.Should().Contain(e => 
    e.ErrorMessage == ValidationMessages.Common.NameRequired);

Integration Tests

Integration tests using WebApplicationFactory automatically pick up localization since AddGrydAuth() registers AddLocalization() internally:

csharp
[Fact]
public async Task Should_Return_Translated_Error_In_Portuguese()
{
    var client = _factory.CreateClient();
    client.DefaultRequestHeaders.Add("Accept-Language", "pt-BR");

    var response = await client.PostAsJsonAsync("/api/v1/users", new { });

    response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

    var content = await response.Content.ReadAsStringAsync();
    content.Should().Contain("E-mail é obrigatório");
}

Checklist for Developers

Use this checklist when adding localized messages to your application:

  • [ ] Create a message class with const string fields in English
  • [ ] Group messages in nested static classes by feature/domain
  • [ ] Create .resx files for each supported culture (pt-BR, es-ES)
  • [ ] Place .resx files next to the message class
  • [ ] Use the English constant value as the .resx name attribute
  • [ ] Preserve {0}, {1} placeholders in translations
  • [ ] Inject IStringLocalizer<YourMessages> via constructor DI
  • [ ] Use localizer[YourMessages.Section.Constant] in code
  • [ ] Use localizer[YourMessages.Section.Constant, arg1, arg2] for parameterized messages
  • [ ] Never translate ErrorCodes — they are locale-invariant
  • [ ] Never compare ErrorMessage strings for control flow
  • [ ] Add mock IStringLocalizer<T> in unit tests
  • [ ] Test with Accept-Language: pt-BR and Accept-Language: es-ES headers

Released under the MIT License.