Appearance
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
| Culture | Language | Status |
|---|---|---|
en-US | English (US) | Default |
pt-BR | Portuguese (Brazil) | ✅ Included |
es-ES | Spanish (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:
| Method | Example | Priority |
|---|---|---|
Accept-Language header | Accept-Language: pt-BR | 1st |
| Query string | ?culture=pt-BR&ui-culture=pt-BR | 2nd |
| Cookie | .AspNetCore.Culture=c=pt-BR|uic=pt-BR | 3rd |
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/usersIf 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) holdconst stringvalues in English. These constants serve double duty — as the default value and as the resource key. .resxfiles contain translations keyed by the exact English constant value.IStringLocalizer<T>resolves the translated string at runtime based on the current culture. If no.resxmatch 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
| Section | Description | Example Constants |
|---|---|---|
Authentication.Login | Login flow messages | UserNotFound, InvalidCredentials, AccountLocked |
Authentication.FirstLogin | First login / password change | PasswordMustBeDifferentFromTemporary |
Authentication.Token | JWT token messages | TokenExpired, TokenInvalid |
Authentication.RefreshToken | Refresh token messages | RefreshTokenExpired |
Authentication.Logout | Logout messages | LogoutSuccess, GlobalLogoutCompleted |
Authentication.SwitchTenant | Tenant switching | TenantSwitchSuccess |
Validation.Password | Password rules | Required, MinLength, MaxLength |
Validation.User | User validation | IdRequired, EmailRequired |
Authorization.Role | Role access control | RoleNotFound, CannotDeleteAdminRole |
Authorization.Permission | Permission control | PermissionNotFound |
Authorization.AppId | AppId management | AppIdNotFound |
MultiTenancy | Tenant operations | TenantNotFound, UserNotAssignedToTenant |
GeoLocation | Location-based security | LatitudeOutOfRange, ImpossibleTravelDetected |
CircuitBreaker | Circuit breaker status | ServiceOpen, ServiceClosed |
ErrorCodes | Locale-invariant codes | Tenant.NotFound, User.NotFound |
ValidationMessages
FluentValidation messages for input validation.
Namespace: GrydAuth.Application.Resources.Messages
| Section | Description | Example Constants |
|---|---|---|
Common | Shared validations | DescriptionMaxLength |
User | User field validation | EmailRequired, EmailInvalid, FirstNameMaxLength |
Password | Password validation | Required, CurrentRequired, MustBeDifferent |
AppId | AppId validation | Required, MaxLength, InvalidFormat |
Role | Role validation | NameRequired, IdRequired |
Permission | Permission validation | NameRequired, CategoryMaxLength |
Tenant | Tenant validation | NameRequired, DomainInvalid |
TenantAssignment | Assignment rules | EndDateAfterStart, MetadataMaxLength |
ProfilePicture | Upload validation | InvalidExtension, FileSizeExceeded |
PasswordReset | Reset token validation | TokenRequired, TokenInvalidFormat |
Network | Network validation | IpAddressMaxLength, UserAgentMaxLength |
SocialLogin | Social auth validation | ProviderRequired, AccessTokenRequired |
ExceptionMessages
Core exception messages for ProblemDetails HTTP error responses.
Namespace: Gryd.API.Messages
| Section | Description | Example Constants |
|---|---|---|
Titles | HTTP error titles | BadRequest, NotFound, Conflict |
Details | Error detail messages | RequiredParameter, EntityNotFound |
ErrorTypes | RFC 7807 error type URIs | (locale-invariant — not translated) |
InfrastructureMessages
Cache and database infrastructure error messages.
Namespace: GrydAuth.Infrastructure.Resources.Messages
| Section | Description | Example Constants |
|---|---|---|
Cache.Titles | Cache error titles | ConnectionFailed, Timeout |
Cache.Details | Cache error details | OperationFailed, CircuitBreakerOpen |
Database.Titles | Database error titles | ConnectionFailed, Timeout |
Database.Details | Database error details | OperationFailed, 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 ← NEWStep 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-idExpected 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.resxExample 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| File | Purpose |
|---|---|
ProductMessages.pt-BR.resx | Portuguese translations |
ProductMessages.es-ES.resx | Spanish translations |
ProductMessages.fr-FR.resx | French 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.resxFormat 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 stringfields in English - [ ] Group messages in nested static classes by feature/domain
- [ ] Create
.resxfiles for each supported culture (pt-BR,es-ES) - [ ] Place
.resxfiles next to the message class - [ ] Use the English constant value as the
.resxnameattribute - [ ] 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
ErrorMessagestrings for control flow - [ ] Add mock
IStringLocalizer<T>in unit tests - [ ] Test with
Accept-Language: pt-BRandAccept-Language: es-ESheaders