Skip to content

Internationalization

An app with no i18n block in hery.config.ts speaks English only, and nothing about that changes: message on every DomainException is a hardcoded English string, exactly as before this existed.

Terminal window
export default {
i18n: {
supportedLocales: ['en', 'fr'],
defaultLocale: 'en',
},
} satisfies HeryConfig;

supportedLocales is what a caller can ask for; defaultLocale is what they get when they ask for nothing, or for something not on that list. Omitting the block entirely is the same as { supportedLocales: ['en'], defaultLocale: 'en' } — English, unconditionally.

LocaleMiddleware reads the caller’s Accept-Language header once per request, negotiates it against supportedLocales (matching a region variant like fr-FR to a declared fr), and stores the result in an AsyncLocalStorage context — the same pattern TenantContextStorage uses for the tenant, so nothing downstream takes a locale as a parameter.

The framework’s own text is message on the error envelope, and only that. The content a tenant writes is translated by a field declared translatable, described below. key is a stable identifier and never changes with the locale; details stays as-is, since a client parses it structurally rather than reading it.

A DomainException subclass is translated by adding an entry to the catalogue in src/technical/i18n/catalogue.ts, keyed by its translationKey (defaults to key — set it explicitly only when key itself is dynamic, or shared by more than one exception):

Terminal window
export const CATALOGUE: Catalogue = {
'team.noCurrentTeam': {
fr: 'Rejoignez une équipe avant de créer des enregistrements qui lui appartiennent.',
},
'record.notFound': {
fr: (details) => `${details.resource} introuvable.`,
},
};

A template can be a plain string or a function of details, for messages built from a value the exception carries (RecordNotFoundException names the resource in details.resource for exactly this).

A key with no catalogue entry for the caller’s locale is served in English — the hardcoded message is the fallback, not an error. CapabilityForbiddenException’s capability.forbidden is deliberately never added: a security message that varies by locale is a signal about itself, so it stays in English regardless of what a project’s catalogue declares.

Zod’s own field-level validation messages (details.fieldErrors on a 400) are a separate mechanism entirely — this only reaches DomainException.message and the generic internal.error/http.error cases the filter builds itself.

A blueprint string field takes translatable: true, and the column becomes Json, holding one value per locale:

Terminal window
fields:
- name: summary
type: string
optional: true
translatable: true

Only a string field on a routed resource can be translatable; hery generate refuses anything else.

Terminal window
i18n: {
supportedLocales: ['en', 'fr', 'de'],
defaultLocale: 'en',
fallbacks: { de: ['fr'] },
}

A read resolves the value for the request locale, then that locale’s fallbacks, then defaultLocale. A value no locale in the chain has comes back as null.

Every response carries the resolved text under the field’s own name and all the stored locales next to it as <field>Translations, so an editor can show one input per locale:

Terminal window
{ "summary": "Bonjour", "summaryTranslations": { "en": "Hello", "fr": "Bonjour" } }

GET /describe marks the field translatable: true and publishes locales (supported, default, fallbacks). Responses on a cacheable route now carry Vary: Accept-Language.

A plain string writes the locale of the request. An object writes any number of locales at once. On update the locales sent are merged into the stored ones, and a null removes one:

Terminal window
{ "summary": "Hello" }
{ "summary": { "en": "Hello", "fr": "Bonjour" } }
{ "id": "...", "summary": { "fr": null } }

Each locale must be in supportedLocales, and each value obeys the field’s own rule (a string of 1 to 255 characters). A required field cannot lose its last translation: that update answers translatable.empty.

All three act on the value resolved for the request locale. Filters accept =, !=, like, not like, in and not in; ordering and range operators on translated text are refused. A row with no value in the chain matches no filter.

Prisma can neither order nor text-search a JSON value by key, so these are settled by the service in memory rather than in SQL: a filter or a keyword search reads the translated column of every row the caller may see, and a sort reads the sort keys of every matching row before cutting the page. That is cheap for a few thousand rows and a cost to measure beyond that. A cursor cannot follow a sort on a translatable field and is refused. External search engines index every locale of the field as one text, so they match any locale, not only the resolved one.