Skip to content

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 English message + typed params, and the FE maps code (and discriminators like rule/reason) to i18n keys and interpolates params. 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_FAILED field errors keyed by class-validator rule; IMPORT_VALIDATION_FAILED row errors keyed by ImportErrorCode; SELECTION_VALIDATION_FAILED.violations[].kind) + interpolation ({{name}}, {{max}}, {{count}}). ≈300 strings/lang.
  • params plays 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 (messagemessages, 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 against ErrorCode.

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 params object. Keep data collections. Promote field (and Category-2 id-lists like missing/uncoveredGradeIds/gaps) to prose-free locators.
  • Non-collection list data (gaps[], missing[]) stays structured + a top-level summary messages; 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 emit messages{en,it} alongside existing message/params (purely additive, non-breaking). AppException still accepts message, used as {en,it} fallback where the catalog is incomplete. FE may begin consuming messages.
  • Phase 2 — Complete the catalog; codemod the message arg out of every AppException call site; change the constructor signature to (code, statusCode?, options?); flip on the boot-time coverage assertion. Legacy message/params still emitted for lagging FE.
  • Phase 3 — Once the FE fully consumes messages, drop message/params from the envelope (breaking; coordinate with FE) → messages-only end state.

Swagger considerations

  • New LocalizedMessage schema ({ en, it }). All 7 error DTOs reference it for messages; params removed; data/field retained. During Phases 1–2, DTOs may document both message (deprecated) and messages.

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/params from the envelope — Phase 3, gated on FE fully consuming messages — 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 interpolation params; 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; params dropped); collection-leaf enrichment for VALIDATION_FAILED/IMPORT_VALIDATION_FAILED/SELECTION_VALIDATION_FAILED (leaf messages + retained locators).
  • src/common/i18n/error-messages.drift.spec.ts — every ErrorCode and every enumerable discriminator union member resolves to an entry in every SUPPORTED_LANGS; every template's {{placeholder}} ⊆ that code's ErrorParamsMap keys.
  • 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, promoted field, no params; Prisma-path CONFLICT/NOT_FOUND → generic wording, no per-model-name translation).
  • Update src/common/utils/flatten-validation-errors.spec.ts — leaves retain field/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 assert messages.en/messages.it presence + shape on: a not-found, a discriminated SETUP_VALIDATION_FAILED/SELECTION_WINDOW_INVALID_DATES, a VALIDATION_FAILED with per-leaf messages, and an IMPORT_VALIDATION_FAILED.
  • Manual verification: hit an endpoint that throws each category; confirm messages renders 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.