Error Handling¶
The backend returns structured error responses with a stable code field. The frontend uses this code as the i18n key to display localized error messages.
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.
import { AppException, ErrorCode } from '../common';
import { HttpStatus } from '@nestjs/common';
// Simple — code + message + status is sufficient
throw new AppException(ErrorCode.AUTH_TOKEN_INVALID, 'Invalid token', HttpStatus.UNAUTHORIZED);
// With params — frontend i18n interpolation
throw new AppException(ErrorCode.NOT_FOUND, 'Student not found', HttpStatus.NOT_FOUND, {
params: { entity: 'student' },
});
// With data — structured error payload (import pipeline, validation)
throw new AppException(ErrorCode.IMPORT_VALIDATION_FAILED, '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.
- Every error carries a stable code for frontend i18n keying.
- New codes must be added to the ErrorCode enum (src/common/constants/error-codes.ts).
- Use UPPER_SNAKE_CASE for all error codes.
2. Response Shape¶
Every error response has at least these 5 base fields:
{
"statusCode": 404,
"code": "NOT_FOUND",
"message": "Student not found",
"timestamp": "2026-03-10T12:00:00.000Z",
"path": "/api/v1/students/abc-123"
}
Some error codes add typed params or data fields — see Typed Swagger Schemas below.
| Field | Description |
|---|---|
statusCode |
HTTP status code |
code |
Stable, machine-readable string. Frontend uses this as i18n key |
message |
English human-readable string for logs/debugging |
params |
Optional typed object — interpolation values for the frontend translation template (only on specific DTOs) |
data |
Optional typed payload — structured details like validation errors (only on specific DTOs) |
timestamp |
ISO 8601 timestamp |
path |
Request URL path |
Frontend translation example:
{
"NOT_FOUND": "{{entity}} not found",
"SETUP_STEP_MISMATCH": "Expected step {{expected}}, received {{received}}",
"AUTH_TOKEN_REUSE": "Security alert: session was reused. Please log in again."
}
3. Error Code Registry¶
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. |
HTTP_<status> |
varies | — | Fallback for standard NestJS HttpException subclasses without a custom code. |
Authentication Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
INVALID_CREDENTIALS |
401 | — | Email exists but password does not match. |
AUTH_TOKEN_INVALID |
401 | — | JWT expired, malformed, or revoked. Refresh token invalid/expired. User record not found. |
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. |
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/column missing or stored value incompatible with column type (Prisma P2021/P2022/P2023). Run pending migrations. |
DATABASE_ERROR |
400 | — | Unmapped Prisma error code. |
Academic Year Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
ACADEMIC_YEAR_NOT_FOUND |
404 | — | 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. |
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. |
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:
reason |
When | Example field |
|---|---|---|
date_range_invalid |
startDate >= endDate | academicYear, 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 |
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 |
delete_standard_type |
attempt to delete a built-in room type | roomTypes |
rename_standard_type |
attempt to rename a built-in room type | roomTypes |
unresolved_room_type |
room type not found after sync | roomTypeName |
duplicate |
unique constraint violation (belt-and-suspenders) | rooms, curricula, studyPlans |
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 |
missing_hours |
neither yearLessons nor weeklyLessons provided | subjects, option blocks |
min_exceeds_max |
option block minSelections > maxSelections | optionBlocks |
max_exceeds_subjects |
maxSelections > number of subjects in block | 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",
"message": "Import validation failed",
"data": {
"errors": [
{ "code": "FIELD_INVALID", "column": "gender", "rows": "2-5, 8", "allowedValues": ["MALE", "FEMALE"] },
{ "code": "HEADERS_MISSING", "column": "headers", "rows": "1", "params": ["last_name"], "allowedValues": ["first_name", "last_name"] }
]
}
}
| 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",
"message": "Validation failed",
"data": {
"errors": [
{ "field": "email", "rule": "isEmail" },
{ "field": "anagraphic.firstName", "rule": "isNotEmpty" },
{ "field": "anagraphic.firstName", "rule": "maxLength", "params": { "max": "100" } }
]
}
}
Each error has:
- field — dot-notation path to the field
- rule — class-validator constraint name (e.g. isEmail, isNotEmpty, maxLength, minLength)
- params — optional constraint parameters for interpolation (e.g. { max: "100" })
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) |
20 simple codes (AUTH_*, RATE_LIMITED, INSUFFICIENT_SCOPE, 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> extends HttpException {
constructor(
code: ErrorCode,
message: string,
statusCode?: HttpStatus, // default: 500
options?: { data?: TData; params?: Record<string, string> },
);
}
Source: src/common/exceptions/app.exception.ts