Error Handling¶
The backend returns structured error responses with a stable code field and a pre-rendered, FULL-SEMANTIC messages object localized in every supported language (en_US + it_IT). Every message is self-contained — it names the field (localized label), the enclosing elements, the entity type, and human-readable values; the frontend renders messages[lang] verbatim with zero per-code logic. Message text is owned by the i18n catalog (src/common/i18n/error-messages.catalog.ts); display labels by the label catalogs under src/common/i18n/labels/.
1. AppException — The Only Throw Pattern¶
All errors are thrown via AppException. Never throw NestJS HttpException subclasses (NotFoundException, ConflictException, etc.) — use AppException with the appropriate ErrorCode instead.
AppException carries no message text. The user-facing string lives in the i18n catalog, keyed by code (+ any params.reason / params.kind discriminator) and rendered in every language by AllExceptionsFilter. Throw sites pass only code, an optional statusCode (defaults to 500), and optional structured params / data.
import { AppException, ErrorCode } from '../common';
import { HttpStatus } from '@nestjs/common';
// Simple — code + status
throw new AppException(ErrorCode.AUTH_TOKEN_INVALID, HttpStatus.UNAUTHORIZED);
// With params — structural locators + interpolation values (NOT translated text)
throw new AppException(ErrorCode.NOT_FOUND, HttpStatus.NOT_FOUND, {
params: { entity: 'student' },
});
// With data — structured error payload (import pipeline, validation)
throw new AppException(ErrorCode.IMPORT_VALIDATION_FAILED, HttpStatus.UNPROCESSABLE_ENTITY, {
data: { errors },
});
Rules:
- Always use AppException — never throw new NotFoundException() etc.
- Never catch exceptions just to re-throw — let AllExceptionsFilter handle formatting.
- Never pass message text at the throw site — add/adjust it in the i18n catalog, keyed by code (+ discriminator). A new code without a catalog entry fails assertCatalogCoverage() at boot.
- New codes must be added to the ErrorCode enum (src/common/constants/error-codes.ts) and the catalog (en_US + it_IT), plus DISCRIMINATOR_COVERAGE if the code carries a reason/kind.
- Prefer structured params (ids, field paths, counts) over prose — the catalog interpolates them; the message stays catalog-owned.
- A discriminator (reason/kind) must be thrown under params, never data. resolveCatalogKey reads the discriminator from params only; a code that documents CODE.<reason> messages but throws the reason under data silently renders the generic code-level message. The renderer consumes params entirely: text params are interpolated, the collection array (for registered collection codes — may arrive via params or data) surfaces as slim data leaves, and everything else (internal ids, declared in ERROR_INTERNAL_PARAMS) stays server-side. params is never echoed on the wire.
- Every param must be classified — text (ERROR_TEXT_PARAMS), reserved (reason/kind/field), collection (COLLECTION_ENRICHMENTS.arrayField), or internal (ERROR_INTERNAL_PARAMS). An unclassified param fails the classification-totality drift assertion. When in doubt, put it in the message.
- Use UPPER_SNAKE_CASE for all error codes.
2. Response Shape¶
Every error response carries these base fields:
{
"statusCode": 404,
"code": "NOT_FOUND",
"messages": { "en_US": "Student not found", "it_IT": "Elemento non trovato: Studente" },
"timestamp": "2026-03-10T12:00:00.000Z",
"path": "/api/v1/students/abc-123"
}
messages is the localized object the frontend renders — read messages[lang] verbatim. Neither message nor params is emitted. User-facing details belong in the rendered sentence; internal parameters stay server-side. Some error codes add a top-level field locator; only the collection codes add data — see Typed Swagger Schemas below.
| Field | Description |
|---|---|
statusCode |
HTTP status code |
code |
Stable, machine-readable string. Frontend may branch on it (behavior hook, e.g. auth redirect) |
messages |
Localized, self-contained message, one string per supported language (en_US, it_IT) — pre-rendered + interpolated server-side |
field |
Optional body-relative dotted path of the offending input (Cat. 1 locator; behavior hook for form focus) |
data |
Only declared collection payloads survive: rendered validation/import/selection leaves, mailing-group rejected members, or timetable generation cards and their declared siblings. COLLECTION_ENRICHMENTS is the authority. |
timestamp |
ISO 8601 timestamp |
path |
Request URL path |
Messages are full-semantic¶
messages[lang] is fully rendered server-side — it contains the counts, names, dates, localized field/entity/value labels, and the specific failing rule that describe the error (e.g. "The grade 'Year 7' in Department 'High School': The Name field is required", "Teacher 'Rossi' already exists"). The frontend renders messages[lang] verbatim: no per-code logic, no template table, no params lookup. code and field remain as optional behavior hooks — nothing about the displayed text depends on them.
The label layer¶
The localized label catalogs under src/common/i18n/labels/ back the placeholder modifiers {{key:field}}, {{key:entity}}, {{key:value}} (plus {{key:ordinal}}, which renders a 0-based position as a 1-based human ordinal, "2nd"/"2º"):
| Catalog | Keys | Used for |
|---|---|---|
field-labels.catalog.ts |
every class-validator-decorated DTO property + import column headers | "The Duration field…" / "Il campo Durata…" |
entity-labels.catalog.ts |
lowercased Prisma model names + EntityKey values + hand literals |
"Student not found" |
value-labels.catalog.ts |
every @IsEnum-reachable enum member + enum-typed text params |
"…one of: Male, Female", weekdays, statuses |
container-labels.catalog.ts |
singular nouns for array segments (best-effort, label-fallback) | "Grade 'Year 7' in Department 'High School'" |
Coverage is enforced by labels/label-coverage.drift.spec.ts (mechanical source scans, exact-set equality — a new DTO property, Prisma model, or enum member without a label fails the drift specification). Arrays map element-wise through their modifier and join with ", ". Custom-field names are tenant data and render verbatim. Date-typed params (a Date or a strict ISO string) need no modifier: formatInterpolationValue renders the UTC calendar day per language, always with the year — Wed 7 January 2026 / mer 7 gennaio 2026 (Italian keeps its lowercase weekday/month convention). A YYYY-MM-DD HH:mm–HH:mm string (a slot named in a message) renders as that day followed by the time range.
Enforcement¶
When a request DTO uses a non-Prisma enum, register the actual exported enum in LOCAL_ENUMS in label-coverage.drift.spec.ts and add its member labels in both languages. Shared-login validation follows this path for CredentialPurpose (SET_PASSWORD / USE_EXISTING_PASSWORD); its DTO fields and auth-private Prisma models also need their corresponding field/entity labels. Do not exempt them from the drift scans.
Which params a message must surface is declared in ERROR_TEXT_PARAMS (src/common/constants/error-codes.ts), keyed by full catalog key; entries carry their modifier ({ key, label }) or are plain strings for verbatim interpolation. error-messages.drift.spec.ts checks that the catalog stays aligned; it fails if:
- a declared text param is not interpolated (with the matching modifier) in both languages (completeness),
- a template uses an undeclared {{placeholder}} or the wrong modifier (no orphans), or
- any param of any ErrorParamsMap entry is unclassified — every param must be text (ERROR_TEXT_PARAMS), reserved (reason/kind/field), collection (the code's COLLECTION_ENRICHMENTS.arrayField), or explicitly internal (ERROR_INTERNAL_PARAMS, server-side only). A known-but-unspoken detail is mechanically impossible.
Discriminated codes (CODE.<reason|kind>) render a specific sentence per branch. Presence variants (PRESENCE_VARIANTS in render-error-messages.ts) select a richer sentence when a param is present without being a discriminator: NOT_FOUND.entity / CONFLICT.entity name the entity, CONFLICT.named additionally quotes the offending value; the placeholder-free base sentences remain for entity-less throws (third-party Nest exceptions, Prisma errors without a model name). Registered collections compose their envelope from message-bearing leaves (up to 3 leaf sentences joined ;, plus a localized (+N more)), so a top-level-only consumer still sees the specifics.
2a. Locating an error¶
A failure may identify an input field, a containing collection, the whole request, or an entity state. Do not promise a field path for every error.
| Kind | Wire locator | Detail |
|---|---|---|
| Located input | Body-relative field, including numeric array indices |
Rendered message names the field and rule. |
| Missing or uncovered elements | Containing field where available |
Identifiers are renderer inputs; only a registered collection exposes message-bearing data. |
| Request precondition | Usually no field |
Envelope message explains the precondition. |
| Entity state, auth or permission | Usually no field |
Internal entity/discriminator params are rendered or discarded, never echoed. |
Use literal request DTO keys and joinPath(prefix, ...segments) from src/common/utils/join-path.ts. The owning endpoint determines the prefix: a scope-grouped DTO, a flat DTO, and a setup step need different paths. Do not copy a domain-relative path without checking its controller's actual body shape.
The field-path drift spec covers representative payloads. A database uniqueness fallback may identify only a collection when the failing element cannot be recovered; pre-validation should provide a precise path when possible.
3. Error Code Registry¶
Canonical source:
src/common/constants/error-codes.ts—ErrorCodeenum +ErrorParamsMap(typedparamsshape per code). This chapter explains shared error patterns; per-domain codes for Homerooms / Subject Groups / Timetable Templates / Evaluation Scales / Curriculum Selection / Invitations / Files / Referents / Rollover / Attendance (incl.ATTENDANCE_ENTRIES_SPAN_GROUPS— see chapter 19 §13) / Grades are documented inline in their chapters and the canonical source. TheErrorParamsMapinterface ties every code to its strongly-typedparamsshape — theAppExceptionconstructor refuses to compile if a call site sends the wrong shape.
Choosing a code¶
Read ErrorCode, ErrorParamsMap and the renderer together. Generic fallback codes cover framework/database failures; domain services should select the specific existing code for their rule. Add a new code only when consumers need a distinct contract.
The global filter maps recognized Prisma failures to structured errors, logs unexpected failures and returns INTERNAL_ERROR without raw details. P2002 mapping must support the constraint-name-only shape produced under RLS; use src/common/utils/prisma-p2002.ts. A missing row and an inaccessible row normally share the domain's hidden 404.
Authentication TTLs and route throttles belong in the authentication/configuration sources; do not duplicate a second numeric table here. Setup and temporal error discriminators remain internal renderer inputs. The client displays messages[lang], including for setup errors.
Import Validation Error Codes¶
Top-level envelope uses IMPORT_VALIDATION_FAILED (422). The pipeline-internal leaves carry an ImportErrorCode + locators (column/rows/allowedValues); the renderer weaves all of them into each leaf's sentence and emits slim, message-only leaves. The envelope is composed from the leaves.
{
"statusCode": 422,
"code": "IMPORT_VALIDATION_FAILED",
"messages": {
"en_US": "Column 'Gender', rows 2-5, 8: the value must be one of: Male, Female; Column 'Last name', rows 4: a value is required",
"it_IT": "Colonna 'Genere', righe 2-5, 8: il valore deve essere uno tra: Maschio, Femmina; Colonna 'Cognome', righe 4: il valore è obbligatorio"
},
"data": {
"errors": [
{ "messages": { "en_US": "Column 'Gender', rows 2-5, 8: the value must be one of: Male, Female", "it_IT": "Colonna 'Genere', righe 2-5, 8: il valore deve essere uno tra: Maschio, Femmina" } },
{ "messages": { "en_US": "Column 'Last name', rows 4: a value is required", "it_IT": "Colonna 'Cognome', righe 4: il valore è obbligatorio" } }
]
}
}
Column names render through the field-label catalog; allowed enum members through the value-label catalog. A leaf with allowedValues is routed to the synthetic import.FIELD_INVALID.allowed_values template (renderer-selected, not a thrown discriminator). Key precedence per leaf, most-specific cataloged key first: import.<CODE>.<reason> (e.g. not_in_department) → import.<CODE>.allowed_values → import.<CODE>. Aggregator leaves always carry a reason (the rule key, usually uncataloged like invalid), so the allowed-values variant is a fallback candidate, never gated on reason absence.
| Code | Description |
|---|---|
HEADERS_MISSING |
Expected columns not found. params lists missing names. |
FIELD_REQUIRED |
A required field is empty. |
FIELD_MAX_LENGTH |
Value exceeds maximum length. |
FIELD_INVALID |
Value doesn't match allowed options. allowedValues lists valid options. |
Validation Errors¶
Produced by the global ValidationPipe (class-validator). Every leaf sentence names its field with a localized label and, when the field sits inside (nested) array elements, is prefixed with a humanized element locator harvested from ValidationError.target during the flatten walk:
{
"statusCode": 400,
"code": "VALIDATION_FAILED",
"messages": {
"en_US": "The Email field must be a valid email address; Grade 'Year 7' in Department 'High School': The Name field is required",
"it_IT": "Il campo Email deve essere un indirizzo email valido; Anno di corso 'Year 7' in Dipartimento 'High School': Il campo Nome è obbligatorio"
},
"data": {
"errors": [
{ "field": "email", "messages": { "en_US": "The Email field must be a valid email address", "it_IT": "Il campo Email deve essere un indirizzo email valido" } },
{ "field": "departments.2.grades.0.name", "messages": { "en_US": "Grade 'Year 7' in Department 'High School': The Name field is required", "it_IT": "Anno di corso 'Year 7' in Dipartimento 'High School': Il campo Nome è obbligatorio" } }
]
}
}
Each leaf is exactly { field, messages }: field is the machine locator (§2a, unchanged), messages the self-contained sentence. isEnum failures weave the localized member labels into the sentence ("…must be one of: Male, Female"). Element display names come from the element's name property by default (DISPLAY_NAME_KEYS in flatten-validation-errors.ts covers exceptions like person-shaped arrays; unnamed elements fall back to a 1-based positional phrase, "Period #2"). The location phrase is composed from the location.* catalog keys + singular container labels.
The envelope messages is composed from the leaves (all collection codes): up to 3 leaf sentences joined with ;, plus a localized (+N more) suffix beyond that. Leaf sentences are self-locating, so no raw-path prefix is added. A leafless VALIDATION_FAILED (never produced by the pipe, possible from a hand throw) keeps the generic code-level catalog text.
Passthrough rows. A COLLECTION_ENRICHMENTS entry can preserve already-rendered leaves with passthrough: true and explicitly listed sibling fields. Mailing-group vetting preserves the caller-supplied person reference beside its message; generation failures preserve violation cards and configured skippedSubjectGroups/solveMetadata siblings. An empty timeout card list still retains its declared siblings. Undeclared data fields are dropped.
Rule keys are runtime constraint names, not decorator names — @IsUUID() emits isUuid, @Length emits isLength, @IsISO8601 emits isIso8601. The catalog is keyed accordingly (validation.isUuid), and src/common/i18n/validation-rules.drift.spec.ts derives the emitted set mechanically (source scan + probe validation) so a new decorator without a catalog entry fails the drift specification in both directions (missing entry / dead entry). Custom @ValidatorConstraint names and the hand-thrown custom-fields rule literals are covered by CUSTOM_CONSTRAINT_RULES / CUSTOM_FIELD_RULES in error-messages.coverage.ts. Unknown rules fall back to validation.default ("The X field is not valid"). The rule itself is renderer input and never emitted. Hand-thrown VALIDATION_FAILED builds these input leaves directly ({ field, rule, allowedValues? }) — type the throw AppException<ValidationErrorInput> (flatten-validation-errors.ts), never against the wire ValidationErrorData (its slim leaves have no rule).
Custom Fields Error Codes¶
Custom field value/definition validation errors use VALIDATION_FAILED with the same leaf shape as the pipe (field + rule, rules like required / invalidDate / invalidSelectValue — catalog-localized like any other rule). Custom field CRUD errors:
| Code | Status | When |
|---|---|---|
VALIDATION_FAILED |
400 | Wrong type, unknown field key, missing required field, invalid SELECT value, SELECT missing options, duplicate options. |
CONFLICT |
409 | Duplicate fieldKey within same tenant+entity. |
FORBIDDEN_FIELDS |
403 | User lacks WRITE access on target entity+scope. |
NOT_FOUND |
404 | Definition not found, or scope not found in permission catalogue. |
4. Typed Swagger Schemas¶
The base shape carries scalar error responses. Code-scoped subclasses document which errors an endpoint returns; collection schemas describe their additional data.
| # | DTO | Codes | data type |
|---|---|---|---|
| 1 | ErrorResponseDto (base) |
Any ErrorCode value not covered below |
— |
| 2 | EntityErrorResponseDto |
NOT_FOUND, CONFLICT | — |
| 3 | StepMismatchErrorResponseDto / StepIncompleteErrorResponseDto / SetupValidationErrorResponseDto |
SETUP_STEP_MISMATCH / SETUP_STEP_INCOMPLETE / SETUP_VALIDATION_FAILED | — |
| 4 | ValidationErrorResponseDto |
VALIDATION_FAILED | ValidationErrorData { errors: ValidationFieldErrorDto[] } — leaves { field?, messages } |
| 5 | ImportErrorResponseDto |
IMPORT_VALIDATION_FAILED | ImportValidationData { errors: ImportValidationErrorDto[] } — leaves { messages } |
SELECTION_VALIDATION_FAILED and SELECTION_WINDOW_PREREQ_FAILED carry message-bearing leaves under violations and gaps. Generation failures carry pre-rendered timetable cards; mailing-group validation carries rejected person references and messages. Declare the exact endpoint schema alongside its Swagger response.
Source files:
- Base: src/common/dto/error-response.dto.ts
- Subclasses: src/common/dto/typed-error-responses.dto.ts
- Collection data: src/common/dto/validation-error-response.dto.ts, src/common/dto/import-validation-error.dto.ts
Swagger examples are derived, not hand-written: ERROR_EXAMPLES (src/common/constants/error-examples.ts) runs each example's spec (code + params/data input) through the real renderer + catalog at module load, so the documented envelopes are the exact wire shape by construction (error-examples.drift.spec.ts additionally rejects params/legacy fields and un-interpolated placeholders).
Runtime note: The AllExceptionsFilter continues to produce plain JSON objects. The typed DTOs are purely Swagger schema documentation — the filter's output already matches the DTO structures.
5. AppException Constructor¶
class AppException<TData = undefined, TCode extends ErrorCode = ErrorCode> extends HttpException {
constructor(
code: TCode,
statusCode?: HttpStatus, // default: 500
options?: { data?: TData; params?: ParamsFor<TCode> }, // params typed per code
);
}
No message parameter — text is resolved from the i18n catalog by AllExceptionsFilter. params is typed per code via ParamsFor<TCode> (a shape mismatch is a compile error; codes with no params entry reject any params).
Source: src/common/exceptions/app.exception.ts; catalog + renderer under src/common/i18n/.
Committed-save timetable notices¶
A successful scheduling-input change may return X-Timetable-Review-Ids. It identifies timetable diagnostic GET targets, not diagnostic results or proof of a new problem. Its lookup runs after commit and can fail softly by omitting the header; it must never turn a committed save into an error.
Keep ordinary validation, permission, temporal and structural failures in their domain error envelopes. Diagnostic notices are a separate successful-response contract; see timetables.