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.
Declaring locales
Section titled “Declaring locales”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.
Resolving a locale
Section titled “Resolving a locale”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.
What gets translated
Section titled “What gets translated”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):
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.
What stays untranslated
Section titled “What stays untranslated”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.
Translatable fields
Section titled “Translatable fields”A blueprint string field takes translatable: true, and the column becomes Json, holding one value per locale:
fields: - name: summary type: string optional: true translatable: trueOnly a string field on a routed resource can be translatable; hery generate refuses anything else.
Fallback chain
Section titled “Fallback chain”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.
Reading
Section titled “Reading”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:
{ "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.
Writing
Section titled “Writing”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:
{ "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.
Filtering, sorting and searching
Section titled “Filtering, sorting and searching”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.