Skip to content

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 — ErrorCode enum + ErrorParamsMap (typed params shape 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. The ErrorParamsMap interface ties every code to its strongly-typed params shape — the AppException constructor 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.