Skip to content

Error Handling

The backend returns structured error responses with a stable code field and a pre-rendered messages object localized in every supported language (en_US + it_IT). The frontend picks its language from messages — it no longer maps codes to translations itself. Message text is owned by the i18n catalog (src/common/i18n/error-messages.catalog.ts); see the backend-error-i18n spec under docs/superpowers/specs/ and chapter references in src/common/i18n/.


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 generic. - 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": "Record not found", "it_IT": "Elemento non trovato" },
  "params": { "entity": "student" },
  "timestamp": "2026-03-10T12:00:00.000Z",
  "path": "/api/v1/students/abc-123"
}

messages is the localized object the frontend renders — read messages[lang]. The legacy English message field has been removed (it was a pure mirror of messages.en_US). params is still emitted but deprecated: it is no longer needed for text (that lives in messages), yet it still carries structural detail (discriminators like reason/kind, ids, counts) that the frontend may branch on — it is removed in a later phase once the frontend has migrated off it and that structure is relocated to data. Some error codes add a top-level field locator or data — see Typed Swagger Schemas below.

Field Description
statusCode HTTP status code
code Stable, machine-readable string. Frontend may branch on it
messages Localized 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)
params Optional typed object. Deprecated for text (superseded by messages); still emitted because it also carries structural detail (discriminators, ids, counts). Removed in a later phase once the FE migrates off it
data Optional typed payload — structured collections (validation/import/violation leaves, each carrying its own messages)
timestamp ISO 8601 timestamp
path Request URL path

Messages are self-contained

messages[lang] is fully rendered server-side — it already contains the counts, names, dates, and the specific failing rule that describe the error (e.g. "This room type is used by 3 rooms and cannot be deleted"). The frontend renders messages[lang] verbatim: no per-code logic, no template table, no params lookup. code/field/data remain as optional behavior hooks (branch on code for an auth redirect, focus field, highlight data rows) — but nothing about the displayed text depends on them.

Which params a message must surface is declared in ERROR_TEXT_PARAMS (src/common/constants/error-codes.ts), keyed by full catalog key. error-messages.drift.spec.ts enforces a bijection and fails the build if: - a declared text param is not interpolated in both languages (completeness), or - a template uses an undeclared {{placeholder}} (no orphans).

Raw ids and collections are behavior-only — they stay in data/field, never woven into text. Discriminated codes (CODE.<reason|kind>) render a specific sentence per branch. Collection codes carry a per-leaf messages, wired via the COLLECTION_ENRICHMENTS registry in render-error-messages.ts (currently VALIDATION_FAILED, IMPORT_VALIDATION_FAILED, SELECTION_VALIDATION_FAILED, SELECTION_WINDOW_PREREQ_FAILED).

Deferred: params and leaf rule/reason are still emitted until the frontend migrates off them (dropped in a later phase). Enum-valued interpolations (weekday, status, step, target) currently render the raw value — a label pass is later polish. ROLLOVER_INVALID_STUDENT_IDS stays generic (its discriminator subkeys are already explanatory).


2a. The error-locating contract (4-category taxonomy)

Every error declares which of four categories it belongs to. This is the once-and-for-all contract the frontend codes against — not "every error has a field", because some errors are about an absence (the offending thing isn't in the payload) or a whole-request precondition.

Category field Extra Examples
1. Located exact body-relative dotted path, numeric array indices (same format as flattenValidationErrors) VALIDATION_FAILED, all SETUP_VALIDATION_FAILED element errors, CURRICULUM_HOURS_*, custom-field value errors, duplicate CONFLICT, in-tx curriculum checks
2. Container + identifiers path to the nearest containing element (or a collection name) a typed data/param naming the absent/uncovered items missing_departments (data.missing), grades_not_covered (data.uncoveredGradeIds), SELECTION_WINDOW_PREREQ_FAILED (params.gaps)
3. Request-level absent missing_year, server-precondition 400s
4. Non-locatable / entity-state absent typed entity params NOT_FOUND, permission/auth, ACADEMIC_YEAR_ARCHIVED, *_IN_USE deletion conflicts

field is a path into this request's body, relative to its root. The same business rule therefore yields a different root per endpoint:

  • Bulk / setup curriculum (POST /configure/setup curriculum step, bulkSync): departments.2.curricula.0.grid.optionBlocks.0
  • Single-resource (POST/PATCH /curricula/:id): optionBlocks.0 (families are flat at the body root — there is no departments/curricula/grid wrapper)
  • Single POST /rooms: lunchShifts.0; setup rooms step: rooms.3.lunchShifts.0

Segments are always the literal request-DTO keys. The format is produced by one helper — joinPath(prefix, ...segments) in src/common/utils/join-path.ts — so it never drifts; src/common/utils/error-field-path.drift.spec.ts proves every Category-1 path resolves against a representative payload.

Residual: the P2002 belt-and-suspenders catch in the curriculum/rooms bulk sync emits a collection-level field (curricula / rooms), not an element path — the failing item isn't identifiable in the catch, and real duplicates are already caught pre-transaction with a full path.


3. Error Code Registry

Canonical source: src/common/constants/error-codes.tsErrorCode enum + ErrorParamsMap (typed params shape per code). Subsections below cover the well-trodden codes; per-domain codes for Homerooms / Subject Groups / Timetable Templates / Evaluation Scales / Curriculum Selection / Invitations / Files / Referents / Rollover 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.

Generic Error Codes

Code Status Params Description
INTERNAL_ERROR 500 Catch-all for unhandled server errors. Real error logged server-side, never leaked.
VALIDATION_FAILED 400 Class-validator failures or custom field validation. See Validation Errors below.
RATE_LIMITED 429 Rate limit exceeded. Global: 10 req/60s. Login: 5 req/60s.
BAD_REQUEST / UNAUTHORIZED / FORBIDDEN 400 / 401 / 403 Fallback codes mapped by AllExceptionsFilter for plain NestJS BadRequestException / UnauthorizedException / ForbiddenException thrown outside the AppException discipline (typically by third-party middleware). Domain code should always throw a specific AppException.

Authentication Error Codes

Code Status Params When
INVALID_CREDENTIALS 401 Email exists but password does not match.
AUTH_TOKEN_INVALID 401 JWT malformed or revoked. Refresh token invalid. User record not found.
AUTH_TOKEN_EXPIRED 401 JWT or refresh token has passed its TTL. Distinct from AUTH_TOKEN_INVALID so the frontend can attempt a silent refresh before forcing re-login.
AUTH_TOKEN_MISSING 401 No access token cookie or Authorization header on a protected route.
AUTH_TOKEN_REUSE 401 Refresh token replay detected — entire token family revoked. Frontend should force re-login.
AUTH_REFRESH_MISSING 401 No refresh token cookie in request.
AUTH_TENANT_INVALID 401 Invalid tenant selection during multi-tenant login.
CSRF_ORIGIN_REJECTED 403 Cookie-bearing request from a disallowed Origin / Referer (CSRF defense in depth).
ACTIVE_PROFILE_NOT_AVAILABLE 400 / 401 The active profile selected/switched to is not available for this user. 400 on select-profile / switch-profile with a profile the user doesn't hold; 401 on refresh when the stored profile is missing from the user's current DB rows, or when the refresh-token row pre-dates the activeProfile column (legacy NULL value).
TENANT_SUSPENDED 403 Tenant is not active (suspended or trial-expired).
USER_DEACTIVATED 403 User account is deactivated.

Permission Error Codes

Code Status Params When
INSUFFICIENT_SCOPE 403 User lacks the required scope+action for the route (ScopeGuard).
ACTION_NOT_PERMITTED 403 User does not have the required action permission (ActionGuard).
NO_AUTHENTICATED_USER 403 Permission guard ran without authenticated user. Configuration error.
FORBIDDEN_FIELDS 403 Write request contains scope groups outside the user's writable scopes (FieldWriteGuard).

Database Error Codes

Code Status Params When
NOT_FOUND 404 { entity } Record not found. entity is the lowercased model/entity name (e.g. "student", "teacher").
CONFLICT 409 { entity } Unique constraint violation. entity from Prisma model name when available.
FOREIGN_KEY_VIOLATION 400 Foreign key constraint failed.
NULL_CONSTRAINT_VIOLATION 400 A required (non-nullable) field received null.
REQUIRED_RELATION_VIOLATION 400 A required relation is missing.
DATABASE_TIMEOUT 503 Database connection pool timeout. Clients should retry.
SCHEMA_OUT_OF_SYNC 500 Table or column missing (Prisma P2021/P2022). Run pending migrations.
SCHEMA_TYPE_MISMATCH 500 Stored value is incompatible with the column type (Prisma P2023). Indicates schema/data drift, typically from a manual DB edit.
DATABASE_ERROR 400 Unmapped Prisma error code.

Academic Year Error Codes

Code Status Params When
ACADEMIC_YEAR_NOT_FOUND 404 { entity?, id? } Provided academicYearId not found or doesn't belong to the tenant (or is DELETED).
NO_ACTIVE_ACADEMIC_YEAR 409 No ACTIVE academic year exists for the tenant. Tenant hasn't completed setup.
ACADEMIC_YEAR_ARCHIVED 409 Mutation attempted on a record belonging to an archived academic year. Create, update, delete, and import are blocked.

Curriculum Selection Window Error Codes

Code Status Params When
SELECTION_WINDOW_ALREADY_OPEN 409 A CurriculumSelectionWindow row already exists for the active academic year.
SELECTION_WINDOW_NOT_FOUND 404 No window row exists for the active academic year.
SELECTION_WINDOW_PREREQ_FAILED 422 { gaps: { departmentId, departmentName, gradeId, gradeName, activeStudentCount }[] } Tried to open a window while one or more (dept, grade) pairs with active students lacks a READY curriculum.
SELECTION_WINDOW_INVALID_DATES 422 { reason: 'start_in_past' \| 'start_after_grace_period' \| 'grace_period_missing' \| 'grace_period_passed', startDate?, gracePeriodEnding? } Date validation against AcademicYear.gracePeriodEnding failed (end date is computed live, never accepted on input).
SELECTION_WINDOW_CLOSED 409 Mutation attempted on a window whose live status is CLOSED (past gracePeriodEnding).

Setup Wizard Error Codes

Code Status Params When
SETUP_STEP_MISMATCH 409 { expected, received } Frontend's currentStep doesn't match backend state. User should reload.
SETUP_INVALID_NAVIGATION 400 Cannot navigate to target step (only backward, same, or next allowed).
SETUP_DATA_REQUIRED 400 Data is required for forward navigation.
SETUP_STEP_INCOMPLETE 400 { step } Step not complete, can't advance. Completeness reasons are surfaced at save time as SETUP_VALIDATION_FAILED (e.g. curriculum coverage → grades_not_covered); this code is the blanket advance-gate backstop.
SETUP_VALIDATION_FAILED 400 { reason, field? } Step-specific business rule validation failed. See reason values below.

SETUP_VALIDATION_FAILED reason values

Frontend uses compound key SETUP_VALIDATION_FAILED.{reason} for translation.

field is a body-relative path (§2a, Category 1) — the column below shows the leaf segment; the full value is indexed from the request root, e.g. departments.0.periods.1 (bulk) or periods.1 (single-resource). Two reasons are Category 2 (the offending item is absent, so they carry identifiers in data instead of an element path): missing_departmentsdata.missing: string[]; grades_not_covereddata.uncoveredGradeIds: string[]. missing_year is Category 3 (no field).

reason When Example field (leaf)
date_range_invalid endDate before startDate (periods — single-day allowed); endDate not after startDate (department calendar) calendarEndDate, periods
period_out_of_bounds period outside year range periods
overlap overlapping periods of same type periods
duplicate_name non-unique name (case-insensitive) departments, grades, periods, roomTypes
ordinal_gap non-sequential ordinal positions departments, grades
missing_departments not all tenant depts represented departments
missing_year no academic year found
invalid_department department not owned by tenant departments, departmentId
invalid_max_capacity room maxCapacity not a whole number ≥ 1 maximumCapacity
unknown_room_type room references a room type not in submission roomTypeName
canteen_missing_shifts canteen room has no lunch shifts lunchShifts
non_canteen_has_shifts non-canteen room has lunch shifts lunchShifts
shift_invalid_range lunch shift endTime <= startTime lunchShifts
shift_overlap overlapping lunch shifts in same room lunchShifts
unresolved_room_type room type not found after sync roomTypeName
duplicate unique constraint violation (belt-and-suspenders) rooms, curricula, subject_groups
invalid_grade grade does not belong to specified department gradeIds
invalid_curriculum curriculum not owned by tenant curriculumId
grade_not_in_curriculum grade not linked to curriculum via CurriculumGrade gradeId
grades_not_covered one or more grades in the year have no subject offering hours (curriculum step can't complete) curricula
missing_hours neither yearLessons nor weeklyLessons provided subjects, option blocks
min_exceeds_max option block minSelections > maxSelections optionBlocks

Import Validation Error Codes

Top-level envelope uses IMPORT_VALIDATION_FAILED (422). Each error inside data.errors[] carries an ImportErrorCode for i18n.

{
  "statusCode": 422,
  "code": "IMPORT_VALIDATION_FAILED",
  "messages": { "en_US": "Import validation failed", "it_IT": "Importazione non riuscita" },
  "data": {
    "errors": [
      { "code": "FIELD_INVALID", "column": "gender", "rows": "2-5, 8", "allowedValues": ["MALE", "FEMALE"], "messages": { "en_US": "The value is not valid", "it_IT": "Il valore non è valido" } },
      { "code": "HEADERS_MISSING", "column": "headers", "rows": "1", "allowedValues": ["first_name", "last_name"], "messages": { "en_US": "Expected columns are missing", "it_IT": "Mancano colonne attese" } }
    ]
  }
}

Each leaf carries a pre-rendered messages object; code (the discriminator) and the locators column/rows/allowedValues are retained. The legacy leaf params (interpolation-only) is no longer emitted.

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). Returns structured field-level errors:

{
  "statusCode": 400,
  "code": "VALIDATION_FAILED",
  "messages": { "en_US": "Validation failed", "it_IT": "Validazione fallita" },
  "data": {
    "errors": [
      { "field": "email", "rule": "isEmail", "messages": { "en_US": "Must be a valid email address", "it_IT": "Deve essere un indirizzo email valido" } },
      { "field": "identity.firstName", "rule": "maxLength", "messages": { "en_US": "Must be at most 100 characters", "it_IT": "Deve contenere al massimo 100 caratteri" } },
      { "field": "identity.gender", "rule": "isEnum", "allowedValues": ["MALE", "FEMALE", "OTHER", "PREFER_NOT_TO_SAY"], "messages": { "en_US": "Must be one of the allowed values", "it_IT": "Deve essere uno dei valori ammessi" } }
    ]
  }
}

Each error has: - messages — pre-rendered { en_US, it_IT } for this field (interpolated server-side from validation.<rule>) - field — dot-notation path to the field - rule — class-validator constraint name (e.g. isEmail, isNotEmpty, maxLength, minLength, isEnum). Deprecated (superseded by messages); retained as a discriminator the frontend may still branch on, removed in a later phase - allowedValues — optional list of valid values, present only when rule is isEnum. Mirrors the import pipeline's allowedValues so the frontend can render the same "pick one of …" hint on JSON writes and file imports. Parsed from the class-validator message in flatten-validation-errors.ts.

The legacy leaf params (interpolation-only, e.g. { max: "100" }) is no longer emitted — its value is baked into messages.

Frontend maps rule to a translated message: validation.isEmail → "Must be a valid email address".

Custom Fields Error Codes

Custom field validation errors use VALIDATION_FAILED with English messages. 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

Swagger documents 7 distinct error response shapes. Each is a DTO class in src/common/dto/ — the base carries no params/data, subclasses add strongly typed fields.

# DTO Codes params type data type
1 ErrorResponseDto (base) Any ErrorCode value not covered by a subclass below (auth, rate-limiting, scope/action denials, window status, etc.)
2 EntityErrorResponseDto NOT_FOUND, CONFLICT EntityErrorParams { entity }
3 StepMismatchErrorResponseDto SETUP_STEP_MISMATCH StepMismatchErrorParams { expected, received }
4 StepIncompleteErrorResponseDto SETUP_STEP_INCOMPLETE StepIncompleteErrorParams { step }
5 SetupValidationErrorResponseDto SETUP_VALIDATION_FAILED SetupValidationErrorParams { reason, field? }
6 ValidationErrorResponseDto VALIDATION_FAILED ValidationErrorData { errors: ValidationFieldErrorDto[] }
7 ImportErrorResponseDto IMPORT_VALIDATION_FAILED ImportValidationData { errors: ImportValidationErrorDto[] }

Source files: - Base + params: src/common/dto/error-response.dto.ts, src/common/dto/error-params.dto.ts - Subclasses: src/common/dto/typed-error-responses.dto.ts - Validation data: src/common/dto/validation-error-response.dto.ts, src/common/dto/import-validation-error.dto.ts

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/.