Backend-owned error i18n (localized messages per response)¶
1. Problem distillation¶
- Error message translation currently lives in the frontend: the backend returns a stable
code+ an Englishmessage+ typedparams, and the FE mapscode(and discriminators likerule/reason) to i18n keys and interpolatesparams. Managing that catalog on the FE has proven painful. - Move the entire message catalog to the backend. Each error resolves to an object of localized messages keyed by language (
{ en, it }), pre-rendered and interpolated server-side, returned in all supported languages every time. The FE picks its language and displays the string — no translation logic. - The translatable surface is not ~150 top-level codes. It's codes + discriminators (
SETUP_VALIDATION_FAILED.<reason>,SELECTION_WINDOW_INVALID_DATES.<reason>,STUDENT_NOT_ELIGIBLE_FOR_SUBJECT.<reason>, …) + per-leaf collection errors (VALIDATION_FAILEDfield errors keyed by class-validatorrule;IMPORT_VALIDATION_FAILEDrow errors keyed byImportErrorCode;SELECTION_VALIDATION_FAILED.violations[].kind) + interpolation ({{name}},{{max}},{{count}}). ≈300 strings/lang. paramsplays two roles today: interpolation values (droppable once messages are pre-rendered) and the error-locating contract (field,data.errors[].field/rows/column, Category-2 id-lists per docs/06 §2a — structural, not prose). Only the interpolation role is dropped; locators survive.
Success criteria (observable behavior that proves this works):
- Any thrown AppException (or mapped Prisma/HttpException) yields a response whose messages object contains a correct, interpolated string for en and it, chosen by discriminator where applicable.
- VALIDATION_FAILED and IMPORT_VALIDATION_FAILED responses carry a messages object on each data.errors[] leaf, alongside its structural locators (field/rows/column/allowedValues).
- The FE can render every error with messages[lang] and zero translation tables; it still branches on code and highlights inputs via field/data locators.
- End-state envelope carries code + messages + locators (field?, data?) and no message/params.
- Boot fails fast if any ErrorCode (or enumerable discriminator member) lacks a catalog entry in any supported language; a drift spec enforces the same in CI plus {{placeholder}} ⊆ ErrorParamsMap keys.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Per-locale date/number formatting via Intl. Date-typed params are formatted to a single readable string (e.g. Mon 7 January) used identically across all languages. (Follow-up if per-locale formatting is ever wanted.)
- Accept-Language negotiation / per-request single-language responses. We always emit all languages.
- i18n of any non-error backend copy (credential emails already live in Resend per-lang; UI chrome stays FE-owned).
- Translating raw DB proper nouns interpolated into messages (subject/room/person names remain data, interpolated verbatim into every language's template).
- Any new ErrorCode, changed HTTP status, or domain-behavior change. This is a transport/formatting refactor only.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/common/filters/all-exceptions.filter.ts |
The single normalize→emit site; the NormalizedError shape; the Prisma/HttpException mapping branches (untouched) |
Today it passes message/params through verbatim; it must instead call a pure render transform and reshape the envelope |
src/common/constants/error-codes.ts (ErrorCode + ErrorParamsMap) |
The typed per-code params contract — the exact interpolation inputs and locator source; kept as-is | Nothing — reused unchanged. AppException only loses its English message arg |
src/common/utils/flatten-validation-errors.ts + src/common/dto/import-validation-error.dto.ts |
The per-leaf { field, rule, params, allowedValues } / { code, column, rows } collection shapes |
Leaves now need a messages object; the filter's collection renderer maps rule/code → catalog key and enriches each leaf |
src/common/utils/error-field-path.drift.spec.ts (docs/06 §2a) |
The drift-spec-as-forcing-function pattern: assert a contract resolves against representative payloads at CI time | New drift spec asserts catalog coverage + placeholder⊆params rather than path resolution |
RLS role probe / definePolicy boot-time drift checks (reference_entity_access_policy_recipe.md, project_rls_full_coverage.md) |
Boot-time assertion that fails fast on incomplete coverage (RLS role probe boot-throw, policy drift checks) | Here it asserts catalog↔ErrorCode coverage at module init |
src/common/dto/typed-error-responses.dto.ts (7 typed error DTOs, docs/06 §4) |
The typed Swagger error-response DTO family | message: string becomes messages: LocalizedMessage; params removed; data/field retained |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | no | n/a — the error filter is request-agnostic; messages are static catalog data, identical across tenants | No tenant-owned rows |
| Academic-year scope | no | n/a — no AY-scoped data | Formatting concern only |
| RBAC entity key | no | n/a — no new entity | No CRUD surface |
| Scopes | none | n/a | No new scope |
| Actions | none | n/a — read/update implicit convention irrelevant here |
No new action |
| Service base | custom | n/a — a pure function module render-error-messages.ts, not a service; wired into the existing AllExceptionsFilter |
No DB access, no injection beyond the filter |
queries.ts shape |
none | n/a — no DB reads | Catalog is a static const |
| Error codes | existing | No new codes. New module src/common/i18n/ holds the catalog + render transform + drift spec. ErrorParamsMap unchanged |
Codes are the catalog keys, not new entries |
| DTO conventions | new + reshape | New LocalizedMessage { en: string; it: string } DTO in src/common/dto/; reshape the 7 error-response DTOs (message → messages, drop params, keep data/field) |
Swagger contract must reflect the new envelope |
| File-backed sub-resources | n/a | n/a | No files |
| Custom fields | no | n/a — custom-field validation errors already surface as VALIDATION_FAILED; they flow through the same collection renderer |
Covered by the validation leaf path |
| Profile completeness | no | n/a | Unrelated |
4. Data model plan¶
Schema deltas¶
- None. No Prisma model, column, enum, or migration.
Migration shape¶
- n/a — no migration.
Indexes and uniqueness¶
- n/a — the catalog is a compile-time
Record. Key uniqueness is guaranteed by TypeScript object-literal keys; the drift spec asserts completeness againstErrorCode.
5. API surface¶
No new endpoints. This changes the shared error envelope returned by every endpoint via the global filter.
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| — | (global error envelope, all routes) | n/a | n/a | ErrorResponseDto family (reshaped) |
Envelope contract (end state)¶
{
"statusCode": 400,
"code": "VALIDATION_FAILED",
"messages": { "en": "Validation failed", "it": "Validazione fallita" },
"field": "identity.email", // promoted locator, present only for located errors (docs/06 §2a Cat.1)
"data": { // structured collections only; each leaf carries messages + locators
"errors": [
{ "field": "identity.email", "messages": { "en": "Must be a valid email", "it": "Email non valida" } },
{ "field": "identity.gender", "allowedValues": ["MALE","FEMALE"],
"messages": { "en": "Must be one of the allowed values", "it": "Deve essere uno dei valori ammessi" } }
]
},
"timestamp": "…",
"path": "…"
}
- Drop the pure-interpolation
paramsobject. Keepdatacollections. Promotefield(and Category-2 id-lists likemissing/uncoveredGradeIds/gaps) to prose-free locators. - Non-collection list data (
gaps[],missing[]) stays structured + a top-level summarymessages; the FE renders those with its own static table chrome.
Rollout (phased — each phase independently shippable)¶
- Phase 1 — Add
src/common/i18n/(catalog + render transform + drift spec) and emitmessages{en,it}alongside existingmessage/params(purely additive, non-breaking).AppExceptionstill acceptsmessage, used as{en,it}fallback where the catalog is incomplete. FE may begin consumingmessages. - Phase 2 — Complete the catalog; codemod the
messagearg out of everyAppExceptioncall site; change the constructor signature to(code, statusCode?, options?); flip on the boot-time coverage assertion. Legacymessage/paramsstill emitted for lagging FE. - Phase 3 — Once the FE fully consumes
messages, dropmessage/paramsfrom the envelope (breaking; coordinate with FE) → messages-only end state.
Swagger considerations¶
- New
LocalizedMessageschema ({ en, it }). All 7 error DTOs reference it formessages;paramsremoved;data/fieldretained. During Phases 1–2, DTOs may document bothmessage(deprecated) andmessages.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none |
*_SCOPES runtime constant |
none |
n/a — no permission surface touched.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
Envelope = code + English message + params, FE owns i18n (docs/06 §2, §3) |
Backend renders localized messages{en,it}; message/params removed at end state |
Eliminate FE-side translation drift/pain; single source of message text | Larger error payload (all langs every time); FE must migrate display path |
AllExceptionsFilter is a fully generic passthrough of data/params |
Filter gains a closed registry of collection shapes (VALIDATION_FAILED, IMPORT_VALIDATION_FAILED, SELECTION_VALIDATION_FAILED) it enriches per-leaf |
Per-leaf messages require shape awareness; only 3 codes carry per-leaf prose | Filter couples to 3 known shapes; guarded by unit + drift specs |
Message text authored at throw site (new AppException(code, 'English', …)) |
Text moves entirely into the catalog; throw sites drop the message arg | Single source of truth; en/it stay in sync; no un-interpolated call-site English | One-time codemod across ~hundreds of call sites (Phase 2) |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
"each error code → object of i18n'd messages, one per lang" (implies flat code → {en,it}) |
~2/3 of the real surface is discriminated (reason/kind/rule/import code) + interpolated + nested — a flat code map undercovers it |
Catalog keyed by CODE | CODE.<discriminator> | validation.<rule> | import.<code>; most-specific-first resolution with code-level fallback; per-leaf rendering for the 3 collection codes |
Resolved |
"drop params" (interpret as remove params/data) |
params/data also carry the §2a error-locating contract (field, rows, id-lists) — structural UI behavior a sentence can't replace |
Drop only interpolation-value params; keep data collections; promote field + id-lists as prose-free locators |
Resolved |
| "return the messages in all langs" (implied per-locale niceties) | Per-locale date formatting adds Intl/dependency weight for marginal value given lists were pushed out of prose |
Format date params to one readable string (Mon 7 January) across all langs; defer per-locale formatting |
Resolved |
9. Deferrals¶
- Per-locale date/number formatting (
Intl, dependency-free) — marginal value in v1; dates rendered as one readable string across langs — follow-up: revisit if product asks for locale-native dates. - Dropping legacy
message/paramsfrom the envelope — Phase 3, gated on FE fully consumingmessages— follow-up: coordinate FE cutover, then remove. - Additional languages (es, …) — catalog is a "new column" extension; drift spec forces coverage when a lang is added — follow-up: add when a market requires it.
- ICU pluralization — deliberately avoided by keeping lists as structured data + summary; small plural surface handled by phrasing — follow-up: adopt a formatter-only lib (
intl-messageformat) for affected templates if rich pluralization is ever needed, without changing the architecture.
10. Open questions¶
- Languages + extensibility →
en+it, catalog structured so a language is an added column. - Envelope shape →
messages-only end state; drop interpolationparams; keep locators (field,data). - Translation depth → full; every discriminator-carried leaf gets
messages; pure-data list items stay structured. - Text ownership → catalog owns all text (en + it); throw sites drop the message arg (Phase 2 codemod).
- Existing FE table → none reusable; author Italian from scratch (English current wording as source).
- Library vs home-grown → home-grown flat catalog + helper; nestjs-i18n rejected (its per-request/single-lang/negotiation model is the opposite of all-langs/filter-centric/typed-params).
- Date formatting → single readable string across all langs; no
Intl.
None open.
11. Verification plan¶
- Unit specs:
src/common/i18n/render-error-messages.spec.ts— key resolution (code,CODE.<reason>,CODE.<kind>, fallback to code for unknown/free-string reason);{{placeholder}}interpolation; date-param → readable-string formatting; locator extraction (field, id-lists promoted;paramsdropped); collection-leaf enrichment forVALIDATION_FAILED/IMPORT_VALIDATION_FAILED/SELECTION_VALIDATION_FAILED(leafmessages+ retained locators).src/common/i18n/error-messages.drift.spec.ts— everyErrorCodeand every enumerable discriminator union member resolves to an entry in everySUPPORTED_LANGS; every template's{{placeholder}}⊆ that code'sErrorParamsMapkeys.- Boot-assertion test — module init throws when a code/discriminator/lang is missing.
- Update
src/common/filters/all-exceptions.filter.spec.ts— asserts the reshaped envelope (messages, promotedfield, noparams; Prisma-path CONFLICT/NOT_FOUND → generic wording, no per-model-name translation). - Update
src/common/utils/flatten-validation-errors.spec.ts— leaves retainfield/allowedValues; messages added by the transform (or here, per final placement decision in the plan). - E2E specs: extend representative suites (e.g.
test/curricula.e2e-spec.ts, a students validation path, an import path) to assertmessages.en/messages.itpresence + shape on: a not-found, a discriminatedSETUP_VALIDATION_FAILED/SELECTION_WINDOW_INVALID_DATES, aVALIDATION_FAILEDwith per-leaf messages, and anIMPORT_VALIDATION_FAILED. - Manual verification: hit an endpoint that throws each category; confirm
messagesrenders correctly interpolated in both langs and locators are preserved.
Patterns: chapter 09 (testing); E2E isolation discipline per existing suites.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-07-03
- Chat reference: design walked through and approved by Fabio in chat 2026-07-03 (5 decisions + no-library + date-as-string); spec reviewed and approved ("go on with the plan").
Until this section is filled, no implementation code is written.