Skip to content

CRUD Patterns

Dense recipe reference for building domain modules. Every pattern here is extracted from working code — canonical examples are called out inline. For permission wiring details see chapter 04. For error handling see chapter 06.


1. Module File Structure

Each domain lives in one folder under src/. The folder boundary is the module boundary. The tree below is the baseline shape for a single-resource module; once a module accumulates multiple sub-resources it folds them into subfolders — see Foldering large modules.

src/<domain>/
├── <domain>.module.ts
├── <domain>.controller.ts
├── <domain>.swagger.ts              # Swagger decorator compositions (sibling-only, not exported)
├── <domain>.service.ts
├── <domain>.service.spec.ts
├── <domain>.queries.ts              # Include/select constants + query functions (sibling-only)
├── dto/
│   ├── scopes/                      # Scope sub-DTOs (one file per scope)
│   ├── create-<domain>.dto.ts       # Composes scope sub-DTOs with @ValidateNested
│   ├── update-<domain>.dto.ts       # PartialType of scope sub-DTOs
│   └── <domain>-response.dto.ts     # Scope-grouped response shape
├── interfaces/                      # Domain interfaces: lookup structures, service contracts
│   ├── <domain>.interfaces.ts       # Named exports — no default exports
│   └── index.ts                     # Barrel for interfaces/
└── index.ts                         # Barrel export: module, service, DTOs

Domain module rules

  • One module per domain — encapsulates controller, service, DTOs, interfaces, tests.
  • Public exports — consume the module barrel ('../students') or an explicitly documented query-only entry point. Private implementation files are not cross-module contracts; query-only entry points can avoid runtime dependency cycles.
  • Consume explicit module contracts — the provider module exports a service/query contract, and the consuming Nest module imports the provider module. Do not bypass ownership by reaching into private implementation files; importing both modules in AppModule alone does not wire a provider into the consumer.
  • Keep AppModule lean — only module imports, no business logic.
  • DTOs in dto/ subfolder — scope sub-DTOs in dto/scopes/. Use PartialType() / OmitType() from @nestjs/mapped-types for Update DTOs.
  • Interfaces in interfaces/ subfolder — types that never leave the backend. Each interfaces/ folder has its own index.ts barrel.

Foldering large modules

Keep the dominant resource at module root and group substantial sub-resources into focused folders, retaining descriptive file names. This is a navigation convention, not a separate Nest module or permission boundary. Follow the owning module's actual layout; do not assume a proposed directory tree exists. The module-root barrel remains its public surface, and sibling-only helpers stay private.

interfaces/ convention

What goes in interfaces/: - Boundary Prisma payload types — Prisma.XGetPayload<{ include: typeof constant }> (e.g. StudentWithRelations, DepartmentWithCounts) - Lookup interfaces — data structures for import or business logic (e.g. ImportLookupData) - Service contracts — interfaces for cross-module consumption - Union/utility types — anything the module or consumers need that is not a request/response DTO

What does NOT go in interfaces/: - DTOs (request/response shapes) — those live in dto/ - Types used exclusively inside a single function — keep those local

Barrel rules: - Module-internal types are imported from ./interfaces (relative to the service), not from the module barrel - Types needed by sibling modules are re-exported from the module's top-level index.ts

Canonical examples: src/auth/interfaces/, src/common/interfaces/, src/permissions/interfaces/


2. Controller Recipe

Use StudentsController for a complete protected CRUD surface. The controller delegates to context-aware service methods; student creation also needs the access context to author enrollment episodes.

@Get(':id')
@RequireScopes(EntityKey.STUDENTS, 'read')
@AppliesPolicy(StudentsReadPolicy)
@ApiGetStudent()
async findOne(
  @Param('id', ParseUUIDPipe) id: string,
  @AccessContext() ctx: RecordAccessContext,
) {
  return this.studentsService.findOneForAccessContext(id, ctx);
}

Use separate read and write policies when relationship-derived read reach exceeds mutation authority. Mutation services evaluate the policy using a grant-qualified role slice; a decorator alone does not impose a row predicate.

Controller rules

  • Controllers are thin — no business logic, no Prisma calls, no conditional branching. Delegate everything to the service.
  • All UUID path params use ParseUUIDPipe — validate IDs before delegation.
  • JSDoc /** */ on controller methods is public API documentation — nest-cli.json enables introspectComments: true, so the swagger plugin pulls every /** */ block above a controller method (or the controller class) into Scalar as the operation/tag description. Treat controller JSDoc as user-facing API docs, not internal developer notes. See §5 JSDoc vs *.swagger.ts.
  • @HttpCode(HttpStatus.NO_CONTENT) on DELETE — NestJS defaults POST to 201, but DELETE must return 204.
  • @HttpCode(HttpStatus.OK) on non-CRUD POSTs — import routes, reorder routes, any POST returning 200.
  • Decorator–logic alignment — if a decorator says @RequireAction(entity, 'create'), the method must call a create service method. Never mix.
  • Permission decorators — use @RequireScopes for GET/PATCH, @RequireAction for POST/DELETE. Never combine both on the same route (see chapter 04).
  • No two modules share a :param route prefix. Don't split a resource path across modules where one controller owns a :param catch (GET /students/:id) and another, in a different module, owns a literal sibling segment (GET /students/eligible-for-homeroom). Express matches routes in controller-registration order, so the literal segment resolves only if its controller registers before the :param controller — a guarantee that silently breaks when module imports are reordered. Precedent (grandfathered, keep): /students/eligible-for-{homeroom,subject-group} live in the homerooms / subject-groups modules alongside students's @Get(':id'). Two safeguards make that safe and must be preserved: (1) :id uses ParseUUIDPipe, so a misorder degrades to a loud 400 rather than a silent wrong-handler; (2) an e2e guard asserts the literal routes reach their own handlers. For new work, mount the literal segment on the same controller that owns :param, or pick a non-colliding prefix — do not add a third cross-module :param-prefix sharer.

Canonical example: src/students/students.controller.ts


3. Service Recipe

Person CRUD services extend BaseTenantedCrudService; specialized domain aggregates use their own services. The base class provides create, findAll, findOne, update, and remove with built-in tenant filtering, academic year resolution, and scope-filtered responses.

@Injectable()
export class StudentsService extends BaseTenantedCrudService<
  StudentWithRelations,   // TRecord — Prisma payload type
  CreateStudentDto,       // TCreate
  UpdateStudentDto,       // TUpdate
  StudentResponseDto      // TResponse
> {
  constructor(
    prisma: PrismaService,
    customFieldsService: CustomFieldsService,
    postCommit: PostCommitCoordinator,
  ) {
    super(prisma, customFieldsService, EntityKey.STUDENTS, 'Student', postCommit);
  }

  /** Which Prisma delegate to use (supports transaction clients) */
  protected getPrismaDelegate(client?: Prisma.TransactionClient) {
    return (client ?? this.prisma).student;
  }

  /** Include clause for all queries — defined in *.queries.ts */
  protected getQueryInclude() {
    return studentQueryInclude;
  }

  /**
   * Maps each permission scope to its fields (or a custom response function).
   * Array form: base service iterates the list and picks fields from TRecord.
   * Function form: full control over the shape returned for this scope group.
   */
  protected getScopeFieldMappings(): Record<string, ScopeMapping<StudentWithRelations>> {
    return {
      identity: [...STUDENT_SCOPES.identity],   // array = simple field list
      assignment: (r) => ({                          // function = custom shape
        enrollmentDate: r.enrollmentDate,
        department: r.department,                    // resolved relation
        grade: r.grade,
      }),
    };
  }

  // Optional lifecycle hooks — positional params, no CreateContext type.
  protected async beforeCreate(
    tenantId: string,
    dto: CreateStudentDto,
    ctx: HookContext,
  ): Promise<void> {
    // Validate, resolve FK IDs, detect duplicates. ctx.academicYearId is set.
  }

  // afterCreate runs INSIDE the $transaction and receives the new record's
  // id (not a hydrated record) plus the tx client to chain related writes.
  protected async afterCreate(
    tx: Prisma.TransactionClient,
    recordId: string,
    tenantId: string,
    dto: CreateStudentDto,
    ctx: HookContext,
  ): Promise<void> {
    // Post-create side effects (related rows, logging) — atomic with the create.
  }

  // The update pair mirrors the create pair:
  //   beforeUpdate(id, tenantId, dto, ctx)
  //   afterUpdate(tx, id, tenantId, dto, ctx)
}

Base service internals

Dynamic scope auto-discovery — toScopedResponse() first iterates this.scopeFieldMappings to build native scope groups from record data. Then it iterates definitionsByScope keys from CustomFieldsService to auto-discover dynamic scopes. Scopes with no native field mappings get auto-created with custom fields only — this is how the others scope works without any service changes.

Custom field projection — pickCustomFields() emits an entry for every defined field, not only those with a stored value: an unset field surfaces with value: null. So an entity read is self-describing — the FE never merges /permissions definitions to learn a field exists (the permission endpoint suppresses definitions while CUSTOM_FIELDS_HIDDEN is enabled). The enrichment shape (CustomFieldValueDto) is produced by one shared pure helper, buildCustomFieldValues(raw, defs) (src/custom-fields/build-custom-field-values.ts), reused by both pickCustomFields and the aggregate/flat services (homerooms, subject_groups) — see chapter 14. A scope with zero defs still emits no customFields key.

Update pre-fetch optimization — when custom field data exists during an update() call, the base service pre-fetches the existing record to merge its current customFields with incoming ones, then applies native updates in memory.

Academic-year scoping — reads can accept a year selector; create resolves the active year. The base update/remove paths load the record's year and reject archived-year writes. Domain entry points can impose stricter active-year rules. Do not treat a read-side year selector as write authority. Creation orders year resolution and writability, before-hook, normalization, custom-field validation, flattening, transaction-local account check, main write and after-hook, then response reload. See the base service for exact hook signatures.

The lifecycle-hook inventory (all no-op defaults; override per entity):

Hook When Notes
beforeCreate(tenantId, dto, ctx) / beforeUpdate(id, tenantId, dto, ctx) pre-validation / DTO transform runs before the base service's local transaction callback, but may already be inside the request transaction; ctx.academicYearId set
afterCreate(tx, recordId, tenantId, dto, ctx) / afterUpdate(tx, id, tenantId, dto, ctx) inside the $transaction, after the main write gets the record id, not a hydrated record; chain related writes on tx
normalizeForPersistence(mode, id, tenantId, dto, ctx) between the before-hook and flattenDto returns { native?, relational?, omitFromNative? } — inject computed native defaults, clear conditional fields, and extract relational (M2M) payloads (relational is Object.assign-ed onto ctx for the after-hook; omitFromNative keys are skipped by flattenDto) without mutating the DTO
getBaseWhere / resolveBaseWhere list/detail visibility pure predicate hook or asynchronous resolved-policy hook; the base welds the tenant after resolution
getInvitationConfig() on create / update / remove when non-null on create, checks the final nonempty nativeData[emailField] inside the transaction before insert/afterCreate; on update, snapshots emailField; for a changed nonempty mailbox, locks the recipient and checks local account ownership/type before writing or resetting access, then invalidates the invitation on email change; on remove releases the person's membership (invalidateByRecipient(…, 'ROLE_DELETED', { actorUserId }), the actor taken from hookCtx.actorUserId or the access context): it ends, keeps or switches off the linked User and refuses the caller's own row or the last admin (membership lifecycle); revokeAccessOnArchive: true makes update() deactivate the linked User + revoke tokens + drop the pending invite on status→ARCHIVED (binding kept), and restore login on ARCHIVED→other — the PeopleStatus {ACTIVE, ON_LEAVE, ARCHIVED} access flow
getRemovePreFetchSelect() + cleanupBeforeRemove(tx, id, tenantId, existing) pre-delete, inside remove()'s transaction atomic cleanup that must commit with the delete (e.g. File rows; defer blob deletion until commit); existing is the pre-fetched row including the extra select columns
beforeRemoveWithinTx(tx, id, tenantId, ctx) / afterRemoveWithinTx(tx, id, tenantId, ctx) inside remove()'s transaction, before / after deleteRecordWithinTx refusals that must be decided under a row lock, and post-delete domain work; ctx is the optional hookCtx a caller passes to remove(id, tenantId, hookCtx), as for create / update. Neither runs for the setup-import sweep, which reaches deleteRecordWithinTx directly

Temporal person updates. Student placement/lifecycle changes use dated command helpers and retain lived episodes. A department/grade change is not permission to delete all membership/selection history. Follow src/students/students.service.ts, student-placement.ts, and the shared temporal helpers; see class organization.

Person account preflight. Generic create uses getInvitationConfig().invalidator.assertRecipientCreationEmailAllowed after normalization and custom-field validation, passing the year being written. It creates no User and claims no email. The custom Referent service checks only genuinely new rows and ordinary changed-email PATCH destinations; reuse/replacement/merge keeps its existing behavior. Student afterCreate checks only missing inline Referents, so rejection rolls back the Student and its relationships. Imports bypass generic create and call the same assertion explicitly (chapter 07). A conflict is 409 LOGIN_EMAIL_IN_USE, naming the holder and carrying getInvitationConfig().emailFieldPath as field, or 409 INVITATION_ACCOUNT_CONFLICT for a broken binding; optional absent/null emails and normalized no-op updates keep their existing behavior. See auth account checks.

Entities not year-scoped (intentionally): RoomType, CustomFieldDefinition, User, Role, School — configuration/catalogue entities.

Duplicate detection scope — checks in beforeCreate hooks and the import classifyRows callback must be scoped to the target year. The same personUuid may appear across years; only same-year matches are considered.

Canonical example: src/students/students.service.ts, src/common/services/base-tenanted-crud.service.ts


4. DTOs & Validation

Scope sub-DTO pattern

Define create, update, and response classes per scope under dto/scopes/. Update classes use the owning module's mapped-type convention; response types express nullable persisted values and scope-specific metadata.

student-identity.dto.ts contains names, date of birth, gender, and media consent. Place of birth, nationality, tax code and the home address belong to private_data; adding them to identity would expose them to broader readers. The private_data block stays required on POST /students even though none of its fields is required. Native scope membership comes from the permission field catalogue, not DTO naming alone.

Validator cheat sheet

Type Decorators
Required string @IsString() @MaxLength(N)
Optional string @IsOptional() @IsString() @MaxLength(N)
Date @IsDate() @Type(() => Date)
Enum @IsEnum(EnumType)
Country code @IsISO31661Alpha2()
Nested required @IsDefined() @ValidateNested() @Type(() => DtoClass)
Nested optional @IsOptional() @ValidateNested() @Type(() => DtoClass)
JSON object @IsOptional() @IsObject()

@ValidateNested() alone does NOT enforce presence. Always pair with @IsDefined() for required nested DTOs.

Wrapper DTO composition

// dto/create-student.dto.ts
export class CreateStudentDto {
  @IsDefined() @ValidateNested() @Type(() => CreateStudentIdentityDto)
  identity: CreateStudentIdentityDto;       // required scope

  @IsOptional() @ValidateNested() @Type(() => CreateStudentHealthDto)
  health?: CreateStudentHealthDto;               // optional scope
}

// dto/update-student.dto.ts
export class UpdateStudentDto {
  @IsOptional() @ValidateNested() @Type(() => UpdateStudentIdentityDto)
  identity?: UpdateStudentIdentityDto;

  @IsOptional() @ValidateNested() @Type(() => UpdateStudentHealthDto)
  health?: UpdateStudentHealthDto;
}

Response shape

// dto/student-response.dto.ts
export class StudentResponseDto {
  id: string;
  personUuid: string;
  academicYearId: string;
  identity?: StudentIdentityResponseDto;     // all scopes optional —
  health?: StudentHealthResponseDto;              // FieldFilterInterceptor strips
  createdAt: Date;                               // unauthorized groups
  updatedAt: Date;
}

Canonical example: src/students/dto/

List endpoint query DTOs

The global ValidationPipe runs with forbidNonWhitelisted: true (src/main.ts), so any query param not declared on the bound DTO is rejected with VALIDATION_FAILED (whitelistValidation). Keep that safety net — it catches typos and silently-ignored filters — and make the contract explicit per endpoint.

A list query is composed of four orthogonal pieces. Each lives on a shared base; the per-entity DTO extends the right base and adds the typed filter columns + sortBy allowlist on top.

Piece Source Notes
Pagination PaginationQueryDto page (default 1, min 1), limit (default 20, max 100).
Year scoping AcademicYearQueryDto academicYearId optional; defaults to active year via resolveActiveYear.
Sort direction SortQueryDto sortOrder only; the sortBy allowlist is per-entity (@IsIn).
Search + filters per-entity ListXQueryDto q, typed filter fields, multi-value via @TransformToArray.

Bases (all in src/common/dto/):

  • PaginatedListQueryDto — pagination + year + sortOrder. Year-scoped people-tables (Students, Teachers, Staff).
  • BasicPaginatedListQueryDto — pagination + sortOrder. Non-year entities (Users, Referents).
  • AcademicYearPaginationQueryDto — pagination + year only (no sort). For endpoints without additional filters (Rooms, Departments, Grades).

Operator-by-type convention for filters in a ListXQueryDto:

Field type Operator Wire form
Text (name, email) case-insensitive contains ?firstName=mar
FK (uuid) multi-value IN ?gradeId=<uuid>&gradeId=<uuid> or ?gradeId=<uuid>,<uuid>
Enum multi-value IN ?gender=MALE&gender=FEMALE
Boolean exact ?isActive=true
Date range, inclusive ?dateOfBirthFrom=YYYY-MM-DD&dateOfBirthTo=YYYY-MM-DD

Wire mechanics for multi-value filters: pair @TransformToArray() (in src/common/utils/) with @IsUUID('4', { each: true }) / @IsEnum(E, { each: true }) / @IsISO31661Alpha2({ each: true }). The helper handles "single value", "repeated params", "comma-separated string", and the empty cases (which become undefined to avoid the { in: [] } foot-gun that silently matches zero rows).

People-directory status filters are opt-in: GET /students and GET /teachers accept a multi-value status enum using this convention, while omitting status adds no predicate. Callers that need an active-only picker must request the active status explicitly; the server applies it before pagination and counting.

GET /students accepts optional referentQ (string, maximum 100 characters) alongside student search q. Every whitespace-separated term must match the first name, last name, or email of the same linked referent, using case-insensitive substring matching. Omitted, empty, or whitespace-only input adds no predicate. The filter intersects with q, other filters, tenant, academic year, and student visibility before pagination and meta.total. Siblings sharing a matching referent are each eligible; multiple matching referents do not duplicate a student. Example: ?q=Anna&referentQ=rossi%40example.com.

GET /students also accepts hasLunch=true|false: true matches explicit lunch participation; false means No or unset and matches both false and null. Omission (or an empty value, following TransformToBoolean) adds no lunch predicate. Other values, including repeated parameters, are rejected by boolean validation. The filter intersects with the other filters, tenant, academic year and record visibility before pagination and meta.total. The response remains nullable: render null as —; null is retained in storage and responses.

Per-entity buildListArgsForX(query) lives in <domain>.queries.ts. Pure function: typed DTO → ListQueryArgs (the shared shape consumed by BaseTenantedCrudService.findAll and by the standalone Users/Referents findAll methods). No DB calls, no exceptions; trivially unit-testable. The controller wires the DTO straight to the queries-helper output:

@Get()
async findAll(@Query() query: ListStudentsQueryDto, @AccessContext() ctx) {
  return this.studentsService.findAllForAccessContext(ctx, query);
}

Visibility AND composition is load-bearing. BaseTenantedCrudService.findAll ANDs three WHERE fragments: the visibility filter from resolveBaseWhere(), the year filter, and args.where. This cannot be replaced with a spread — a filter clause that named the same key as a visibility clause (e.g. tenantId, referents) would otherwise silently override visibility. The standalone findAll methods on UsersService and ReferentsService follow the same AND pattern.

Scope-sensitive filters. Filtering can reveal a value through IDs and counts even when response stripping hides the field. Student lunch filters require health READ; nationality, tax-code and birth-date filters require private-data READ on the entity whose private_data holds them. Student birth-date filters need no extra grant because a student's date of birth is in identity. WRITE also qualifies, with the standard platform-admin bypass. The service checks supplied sensitive filters before querying. Keep this aligned with the query DTO and service when adding fields.

Rule for @Query() bindings on list endpoints:

  • No filters, no sort → bind AcademicYearPaginationQueryDto / PaginationQueryDto directly.
  • Filters or sort → declare ListXQueryDto extends PaginatedListQueryDto (or BasicPaginatedListQueryDto) in src/<domain>/dto/list-<domain>-query.dto.ts, plus buildListArgsForX(query) in <domain>.queries.ts. Wire the controller to call the queries helper before handing args to the service.
  • Static / lookup endpoints (presets, types, dropdown data) stay non-paginated and use @AggregateResponse().
// src/students/dto/list-students-query.dto.ts
export const STUDENT_SORT_BY = ['lastName', 'firstName', 'enrollmentDate', 'createdAt'] as const;

export class ListStudentsQueryDto extends PaginatedListQueryDto {
  @IsOptional() @IsString() @MaxLength(100)
  q?: string;

  @IsOptional() @TransformToArray() @IsUUID('4', { each: true })
  gradeId?: string[];

  @IsOptional() @IsDate() @Type(() => Date)
  dateOfBirthFrom?: Date;

  @IsOptional() @IsIn(STUDENT_SORT_BY)
  sortBy?: (typeof STUDENT_SORT_BY)[number] = 'createdAt';
}

Do not add a new filter by relaxing the pipe or by silently accepting undeclared params — add the field to the DTO. Modules with no extra filters can bind the shared base directly.

Canonical examples: src/students/dto/list-students-query.dto.ts, src/students/students.queries.ts (buildListArgsForStudents), src/curriculum/dto/list-curricula-query.dto.ts (single-filter case).


5. Swagger

Sibling *.swagger.ts per controller

Swagger decorator compositions live in a sibling file, not inside the controller. The controller only applies the composed decorators.

// students.swagger.ts
export const ApiStudentsController = () =>
  ApiResourceController({ tag: 'students', extraModels: [StudentResponseDto, StudentIdentityResponseDto] });

export const ApiCreateStudent  = () => ApiCreateEndpoint({ responseType: StudentResponseDto });
export const ApiListStudents   = () => ApiListEndpoint({ responseType: StudentResponseDto });
export const ApiGetStudent     = () => ApiGetEndpoint({ resourceName: 'Student', responseType: StudentResponseDto });
export const ApiUpdateStudent  = () => ApiUpdateEndpoint({ resourceName: 'Student', responseType: StudentResponseDto });
export const ApiDeleteStudent  = () => ApiDeleteEndpoint({ resourceName: 'Student' });

CRUD factories (from src/common/decorators/api-crud-helpers.ts)

Factory Applies
ApiResourceController({ tag, extraModels? }) Class-level: @ApiTags, @ApiBadRequestResponse, @ApiTooManyRequestsResponse, @ApiExtraModels
ApiCreateEndpoint({ responseType, conflict? }) @ApiCreatedResponse, @ApiUnauthorizedResponse, @ApiForbiddenResponses
ApiListEndpoint({ responseType }) @ApiPaginatedResponse, @ApiUnauthorizedResponse, @ApiForbiddenResponses
ApiGetEndpoint({ resourceName, responseType }) @ApiOkResponse, @ApiNotFoundResponse, auth responses
ApiUpdateEndpoint({ resourceName, responseType, conflict? }) @ApiOkResponse, @ApiNotFoundResponse, auth responses
ApiDeleteEndpoint({ resourceName }) @ApiNoContentResponse, @ApiNotFoundResponse, auth responses

Tag names are canonical, not free-form. The tag passed to ApiResourceController (and any bare @ApiTags(...)) MUST match a tag declared in src/swagger.config.ts. That file enumerates every tag — kebab-case, each with a short description and a deliberate sidebar order — and is the single source of truth. A controller tag that doesn't match a declared one produces two broken Scalar entries: the declared tag renders described-but-empty (nothing points at it), and an undescribed ghost group appears for the drifted string. Rules:

  • Match the declared kebab tag exactly — 'curriculum', not 'curricula' or 'Curriculum'.
  • Sub-resources fold into the parent's tag — /students/:id/curriculum-selection uses students; /departments/:id/grades uses departments; /selection-windows folds into curriculum. The parent tag's description should mention the sub-resource.
  • A genuinely distinct surface gets its own declared tag (e.g. roles, curriculum-presets) — add it to swagger.config.ts in the same change.
  • Don't declare a tag no controller uses (it renders empty), and don't leave a service-only module with a declared tag — files has no controller; its document endpoints live under students/teachers/staff.

Error response DTO families

Status DTO When
400 (validation) ValidationErrorResponseDto class-validator failures (data.errors[] of ValidationFieldErrorDto)
400 (setup) SetupValidationErrorResponseDto Setup step validation
401, 403, 429 ErrorResponseDto (base) Auth, permission, rate-limit
404, 409 EntityErrorResponseDto Not found or unique constraint; entity labels are rendered into messages
422 ImportErrorResponseDto Import file validation (has data.errors[])
  • ApiForbiddenResponses() takes string keys: 'insufficientScope', 'actionNotPermitted', 'forbiddenFields'
  • Register all referenced error DTOs via @ApiExtraModels() in the controller swagger decorators
  • JSDoc /** */ on controller methods auto-infers Swagger summaries via the NestJS plugin — never duplicate the summary manually

5.5. JSDoc vs *.swagger.ts — where docs live

nest-cli.json sets introspectComments: true for the swagger plugin. That means every /** */ block above a controller method or controller class gets pulled into the generated OpenAPI spec and rendered verbatim in Scalar. Anything an API consumer shouldn't see must not live there.

Three audiences, three destinations:

Audience Destination Form
API consumer (frontend, external integrators) *.swagger.ts → ApiOperation({ description }) Explicit, maintained string
API consumer, terse case Controller method JSDoc /** one-liner */ Plugin picks it up as the operation summary
Internal developer (mechanisms, guard order, why this decorator) Service method JSDoc, or // line comment above the controller method/class Line comments are not JSDoc and are ignored by introspectComments

Rules:

  • Never put internal mechanism detail in a controller /** */ block. No guard names (FieldWriteGuard, ProtectedResource), no {@link EntityKey.X} references, no explanations of guard order, scope stacks, or decorator internals. Those belong in service JSDoc or in // line comments.
  • If the *.swagger.ts entry already sets ApiOperation({ description }), drop the controller method JSDoc entirely — two sources drift.
  • A short user-facing one-liner on a controller method (e.g. /** List rooms (paginated) */) is fine and becomes the Scalar summary automatically.
  • Side effects that matter to API consumers (cascading deletes, upsert-on-conflict, invalidation of related resources) belong in ApiOperation({ description }), not in controller JSDoc.
  • Non-obvious decorator choices (e.g. "the action MUST be 'write' because FieldWriteGuard short-circuits on 'read'") go as // line comments directly above the decorator row — not in JSDoc.
  • Class-level controller JSDoc leaks to Scalar as a tag description. Keep it for user-facing resource summaries only; any "uses N scopes", "protected via ProtectedResource", "shares EntityKey with X" notes go in // line comments above the class.

Canonical example: src/referents/referents.controller.ts + src/referents/referents.swagger.ts — the update/delete/create swagger entries carry the user-facing descriptions; the controller holds only terse summaries and the one // line comment that documents the action: 'write' requirement.

Canonical example: src/students/students.swagger.ts, src/common/decorators/api-crud-helpers.ts

5.6. Swagger decoration discipline — Scalar autoinspects, so don't restate

The docs are rendered with Scalar (/reference), which derives field types, formats, enums, and constraints directly from TypeScript types and class-validator decorators on the DTO. Anything a description string only restates is noise that pushes the genuinely useful entries below the fold.

The same applies to @ApiOperation({ description }) on routes. Description prose is for client-relevant rules the spec cannot infer: state-machine errors, slot cardinality, server-assigned values (Auto-generated T-XXXXX), tri-state PATCH semantics, signed-URL TTL behavior, multipart form-field shapes that a JSON DTO can't express. Sequential server-side flow ("validates headers, then validates rows, then creates records") belongs in code comments or chapter docs, not in the API spec.

Drop the description (keep the decorator + any example / format / enum / maxLength / nullable) if any of these are true:

  • It restates the field name (firstName: 'Student first name', name: 'Department name').
  • It restates a class-validator constraint (@IsEmail description "valid email", @Min(0) description "non-negative").
  • It just narrates the handler's sequential flow ("uploads file, validates, creates...").
  • It is a generic 401/429/400 phrase the status code already conveys ('Missing or invalid access token', 'Rate limit exceeded', 'Validation error').

Keep the description when it adds non-derivable contract info — units the field name doesn't carry (yearLessons → "Total annual hours"), state-machine error codes (Rejects with ACADEMIC_YEAR_NOT_EDITABLE when not DRAFT), scope visibility rules (Configuration scope is stripped for callers without READ on …), or auto-generated value formats.

Shared error-response decorators (ApiResourceController, ApiCreateEndpoint, ApiListEndpoint, etc. in src/common/decorators/api-crud-helpers.ts) cascade across every CRUD endpoint — the ${resourceName} not found and conflict descriptions there are deliberately preserved because they inject entity-specific info; the boilerplate 401/429 strings have been stripped.

Canonical examples: src/students/students.swagger.ts (file-upload routes — keeps single-slot vs collection rules, drops handler narration) and src/common/decorators/api-crud-helpers.ts (the shared cascade).


6. Queries Convention

Every module with any Prisma call beyond BaseTenantedCrudService gets a mandatory <domain>.queries.ts file. There is no threshold — even a single extra query goes in the queries file.

Rules

  • Named query functions accepting the relevant Prisma client/transaction type as the first parameter
  • Keep orchestration in services — query helpers own data access, including writes, locks and CAS predicates; services decide authorization, command flow and domain responses
  • Include constants use as const, derive payload types via Prisma.XGetPayload<{ include: typeof constant }>
  • Prisma payload types stay here — co-located with include consts. Domain interfaces (lookups, contracts) go to interfaces/
  • Sibling-only imports — *.queries.ts is not exported from the module index.ts (unless cross-module usage exists)
  • Every non-standard Prisma call lives here — services import from queries, never inline queries
  • Function names describe intent — findGradeIdsByTenantAndYear, not getGrades

Condensed example

// students.queries.ts
export const studentQueryInclude = {
  department: { select: { id: true, name: true } },
  grade:      { select: { id: true, name: true } },
} as const;

// Derive the payload beside its include; export through interfaces/ when consumers need it
// export type StudentWithRelations = Prisma.StudentGetPayload<{ include: typeof studentQueryInclude }>;

export async function loadImportLookups(
  prisma: PrismaService,
  tenantId: string,
  academicYearId: string,
): Promise<ImportLookupData> {
  // Load the declared lookup projection on the supplied database handle
}

export function findExistingStudentsForDuplicates(
  prisma: PrismaService,
  tenantId: string,
  academicYearId: string,
) {
  return prisma.student.findMany({
    where: { tenantId, academicYearId },
    select: duplicateCheckSelect,
  });
}

Existing query files

Every module with any Prisma call beyond BaseTenantedCrudService ships a <domain>.queries.ts (some carry only include/select constants, most carry constants + named query functions). For example, students has several (students.queries.ts, curriculum-selection.queries.ts, school-relationships.queries.ts, guardians.queries.ts), and modules such as audit-log, attendance, and command-center follow the same convention. Don't treat any enumerated list here as exhaustive; the rule is the contract.

Canonical example: src/students/students.queries.ts

Prisma-backed person-list free-text filters use src/common/utils/person-search-where.ts. The helper trims the query, splits it on one or more whitespace characters, and returns one case-insensitive field OR per token, with those token clauses composed in AND. Thus Marco Rossi and Rossi Marco both match a row whose first and last names contain the two tokens, while tokens cannot be satisfied by different rows. Blank input returns no filter; a complete email remains one token. Each caller owns its searchable field list so entity-specific email and identification-code columns remain available.

The three person worklists under GET /dashboard/{completeness,onboarding,curriculum-selections} use the same helper. Existing firstName, lastName, and email filters remain independent column filters and compose in AND with q. When a base query already carries an OR for authorization, cohort membership, or dated placement, compose personSearchWhere(...).AND below the base query's AND; never spread a second OR at the same object level.


7. Nested Sub-Resources

Pattern for resources scoped under a parent entity, such as grades under departments (/departments/:departmentId/grades).

Controller

The parent path is /departments/:departmentId/grades. Parse parent and child IDs with ParseUUIDPipe, bind the list query DTO, and pass RecordAccessContext to the owning service. Mutation routes use GradesPolicy; the service validates parent membership and qualified authority.

Use grades.controller.ts for signatures and grades.service.ts for the parent-aware methods.

Service

Nested methods validate both parent and child under the tenant and appropriate read/write policy. Thread RecordAccessContext through every path; a parent ID alone is not authority. Use the owning query helpers and compose policy, tenant and parent/child IDs with AND. Domain permission requirements may be separate from the parent's.

Permission setup

Model an independent child capability with its own entity/scopes/actions. A child that shares the parent's capability can use a singular parent-scope gate; choose according to the implemented domain contract, as person documents do.

Canonical example: src/departments/grades.controller.ts, src/departments/grades.service.ts

7.1. Cross-Role Reads on a Single Entity Endpoint

Don't add a route on entity A's controller that returns rows of entity B to solve "role X needs a filtered view of B". Instead, expose one endpoint on B's controller and push the filtering into the service via @AccessContext(). See chapter 04 § Record-Level Access for the RecordAccessContext + <entity>ForAccessContext recipe.

Concretely: referents read their children through GET /students (filtered through StudentsReadPolicy and the service's resolved read predicate), not through GET /referents/me/students. Keep scope evaluation and record filtering on the owning resource.

7.2. Separate surfaces with different authority

A directory, an eligibility picker, a grouped counts board, and a stateful curriculum-selection resource can all mention students while having different permission gates and row sets. Share summary DTOs where their contracts match; preserve each surface's authorization and record policy.

A frontend view can alter columns, grouping, and layout. It cannot determine which records the backend is authorized to disclose. If a proposed view changes admission, record reach, or stateful behavior, model that distinction explicitly in the controller/service contract.

For examples, compare src/homerooms/grouped-homerooms.controller.ts with src/students/curriculum-selection.controller.ts. Curriculum selection uses a gate-only scope and its own read/write policies; it is not a writable field group in the ordinary Student response.


8. Reorder Endpoints

Bulk ordinal update for ordered resources (departments, grades).

Route declaration

Declare the literal PATCH reorder before PATCH :id, use the entity's write scope and policy, and pass the access context to the service. The configuration.items array carries IDs and zero-based ordinal positions. Follow src/departments/departments.controller.ts and its grade counterpart for their current signatures.

Service

Use src/departments/reorder-in-scope.ts: validate the full tenant/year/parent-scoped ID set, duplicates and ordinal constraints, then apply the reorder atomically. A partial ID list must not silently redefine an ordered set.

DTO

export class ReorderItemDto {
  @IsUUID() id: string;
  @IsInt() @Min(0) ordinalPosition: number;
}

export class ReorderDepartmentsDto {
  @IsDefined() @ValidateNested() @Type(() => ReorderConfigurationDto)
  configuration: { items: ReorderItemDto[] };
}

Canonical example: src/departments/departments.controller.ts, src/departments/departments.service.ts, src/departments/dto/reorder.dto.ts


9. PATCH with a Relational Sub-Resource Array

When a PATCH body needs to mutate rows on a join table (e.g. StudentReferentLink), expose them under the symmetric relational scope on the owning entity. Wrap the array in an items field (never a bare array on a top-level scope key). Entries are strict-update: each item selects an existing row by its natural key (e.g. { studentId } + the implicit :id path param); unknown keys throw a dedicated *_LINK_NOT_FOUND code — no upsert semantics. Omitting a row from the payload leaves it untouched. Removing a join-table row is a separate DELETE on the nested resource (e.g. DELETE /referents/:id/students/:studentId); the PATCH never deletes.

Canonical example: UpdateReferentStudentsDto in src/referents/dto/update-referent.dto.ts and its handling in ReferentsService.update.


10. Code Gotchas

Dates and school time

Use the date type required by the endpoint: transformed Date DTOs for timestamp/date fields, and the shared ISO-date/school-clock primitives for school-local temporal commands. Do not replace school-local date logic with UTC parsing or ad hoc formatting. JSON serialization handles Date values; temporal response mappers own date-only and tick contracts.

Always use AppException for errors

Never throw NestJS built-ins (NotFoundException, ConflictException, etc.). Use AppException with an ErrorCode — every error carries a stable code for client branching plus backend-rendered bilingual messages. See chapter 06.

// CORRECT
throw new AppException(ErrorCode.NOT_FOUND, HttpStatus.NOT_FOUND, { params: { entity: EntityKey.STUDENTS } });

// WRONG
throw new NotFoundException('Student not found');

Never catch just to re-throw

Let AllExceptionsFilter handle formatting. If you catch an exception, either handle it meaningfully or don't catch it at all.

Return DTOs, never Prisma model types

Service methods must return mapped DTO instances. BaseTenantedCrudService.toScopedResponse() handles the mapping for standard CRUD. For custom methods, map explicitly.

ParseUUIDPipe for all UUID path params

// CORRECT
@Param('id', ParseUUIDPipe) id: string

// WRONG — accepts any string, no validation
@Param('id') id: string

Transactions with prisma.$transaction()

Wrap multi-step mutations in a transaction. The transaction client is passed through getPrismaDelegate(client) so base service hooks work inside transactions.

await this.prisma.$transaction(async (tx) => {
  await tx.department.update(...);
  await tx.grade.updateMany(...);
});

nodenext module resolution — .js extensions

Some imports require explicit .js extensions due to nodenext module resolution. If the TypeScript compiler complains about a missing module, add .js to the import path:

import { something } from './my-util.js';

import type for decorator metadata subjects

Use import type for types that are only used as parameter type annotations in decorated classes — avoids circular dependency issues in module metadata:

import type { AuthenticatedRequest } from '../auth/interfaces/index.js';

JWT duration types

Use the shared validated duration parser in src/config/jwt-lifetime.ts and the auth module's signing pattern. Do not silence a configuration/type mismatch with as any.

ConfigService.get() returns string | undefined

Use getOrThrow() for required values to get a compile-time guarantee and a clear runtime error:

// CORRECT
const secret = this.config.getOrThrow<string>('JWT_SECRET');

// WRONG — requires null check everywhere
const secret = this.config.get('JWT_SECRET'); // string | undefined

Test lint configuration

eslint.config.mjs provides scoped Jest/mock relaxations for test files. Do not add blanket eslint-disable headers; use typed mocks and a narrowly justified suppression only when needed. See testing.


11. File-backed sub-resources

Documents attached to a person (Student / Teacher / Staff / Referent) come in two cardinality flavours. Both are served via the files/ module and routed through the owning entity's controller. RBAC piggybacks on each entity's documents scope.

11.1. Two cardinalities, one FileUsage enum

FileUsage values are split by cardinality:

Cardinality Usages Storage Per-person count
Single-slot PASSPORT, IDENTITY_CARD FK column on the owning entity (passportFileId, identityCardFileId) at most one per slot
Collection PERSONAL, EDUCATIONAL, JUSTIFICATION, ATTACHMENT, ASSIGNMENT_ATTACHMENT File row tagged with (ownerType: FileOwnerType, ownerId) — same polymorphic pattern Invitation uses many per owning aggregate, subject to its domain caps

Helpers — isSingleSlotUsage(usage) / isCollectionUsage(usage) — gate which path a request follows. They live in src/files/files.service.ts and are exported from the module barrel.

11.2. Routes

POST   /<entity>/:id/documents                          # multipart: 1..N file parts + usage (+ optional documentNumber / expiryDate for single-slot)
GET    /<entity>/:id/documents                          # list — single-slot + collection merged
GET    /<entity>/:id/documents/:usage                   # single-slot only — mint signed URL
DELETE /<entity>/:id/documents/:usage                   # single-slot only — clear
PATCH  /<entity>/:id/documents/:usage                   # single-slot only — replace (1 file)
GET    /<entity>/:id/documents/by-id/:fileId            # universal — mint signed URL by file id
DELETE /<entity>/:id/documents/by-id/:fileId            # universal — delete by file id
PATCH  /<entity>/:id/documents/by-id/:fileId/metadata   # single-slot only — update documentNumber / expiryDate

POST accepts one or more parts named file (cap: MAX_FILES_PER_UPLOAD = 10). Branch on usage: single-slot requires exactly one file and replaces (delete previous + upload new); sending more than one file rejects with MULTI_FILE_NOT_ALLOWED_FOR_SLOT (400). Collection appends every file in a single transaction — if any file in the batch fails validation or storage, none are persisted (already-written blobs are cleaned up explicitly). Returns FileMetadataDto[] — length 1 for single-slot, length N for collection. Routes that take :usage (GET / DELETE / PATCH of that shape) reject collection usages with INVALID_USAGE_FOR_SLOT (400) — collection items must be addressed by file id, not by slot. PATCH is single-file by design (single-slot only) and returns a single FileMetadataDto.

The by-id/:fileId segment is literal to keep the router unambiguous versus :usage.

GET returns a SignedFileUrlDto JSON envelope (not bytes) — the FE opens / downloads the bytes by GETting the returned url directly from the bucket. The URL is pre-signed, valid for S3_SIGNED_URL_TTL_SECONDS (default 900 = 15 min), and cannot be revoked early — the bucket honours Content-Disposition: inline so PDFs and images render in the browser with the original filename preserved on save. Permission/scope checks run at issuance, which is the audit point; subsequent fetches against the bucket bypass the application.

11.3. System-managed FK columns

The single-slot FK columns (passportFileId, identityCardFileId) are system-managed: FieldWriteGuard rejects PATCH bodies that touch them with FILE_REFERENCE_READ_ONLY. Always go through the document routes. Collection files have no FK on the owning entity, so there's nothing to gate — FieldWriteGuard ignores them.

11.4. Service helpers

FilesService is the choke point for blob I/O and DB writes. Entity services do visibility + auth, then delegate:

Single-slot Collection Universal
replaceFileSlot appendCollectionFiles getSignedUrlByOwner
clearFileSlot deleteFileById
updateFileMetadataById listFilesForEntity (merges both)
cleanupCollectionForOwner (entity hard-delete)

appendCollectionFiles stages blobs and inserts their File rows together, cleaning up staged objects when that operation fails. A local service $transaction may join an already-open request transaction, so do not infer transaction-free I/O from its position in that method.

replaceFileSlot swaps the File row and owning FK transactionally. Deletion of the previous blob is registered through PostCommitCoordinator; it must wait for the real outer commit. Storage cleanup failures are logged without reversing a committed database change.

cleanupCollectionForOwner deletes File rows using the caller's transaction. Transactional callers pass an AfterCommitQueue and hand its flush to the coordinator. The queue-less path is for callers already outside a transaction. A post-commit job that needs database access opens its own tenant GUC transaction.

updateFileMetadataById is the choke point for editing documentNumber / expiryDate on an existing single-slot file. It mirrors deleteFileById's ownership resolution and rejects collection-kind files with INVALID_USAGE_FOR_SLOT.

The storage port (FileStoragePort) exposes put, get, getSignedReadUrl, and delete. Browser document reads use signed URLs; server-side consumers such as email attachment senders can read bytes through get. S3FileStorage uses @aws-sdk/s3-request-presigner to compute the URL locally from the configured credentials (no API round-trip).

Entity hard-delete cleanup runs inside the same transaction as the row delete via the cleanupBeforeRemove hook on BaseTenantedCrudService. Referents' hand-written remove() mirrors the same shape.

Assignment-owned mixed resources

Assignments use the same blob choke point but not the person-document routes. FileUsage.ASSIGNMENT_ATTACHMENT plus FileOwnerType.ASSIGNMENT identify the collection rows, while AssignmentAttachment is the public mixed wrapper for FILE or LINK. Its file relation is one-to-one and restrictive; its Assignment relation cascades. Assignment file validation deliberately narrows the shared communication allowlist to .pdf, .docx, .xls, .xlsx, .csv, .jpg, .jpeg, and .png, with 3 MiB per file, 15 MiB aggregate file bytes, and ten FILE/LINK wrappers.

Direct aggregate create calls the Files validation-only method before I/O, stages blobs through uploadBlob, and persists File rows through attachFile inside the Assignment transaction. A failed aggregate calls discardUploadedBlobs, which deletes staged objects only and never mutates DB rows. Post-create delete first removes the wrapper/File row inside the owning transaction and queues the blob delete through AfterCommitQueue plus PostCommitCoordinator. Parent hard delete cascades wrappers before cleanupCollectionForOwner removes Assignment-owned File rows. Signed URLs are minted only after the Assignment module has independently admitted the school or family parent.

11.5. Canonical examples

  • src/students/students.controller.ts — full route surface (admin / teacher / referent allowlist + assertReferentCanWrite gate).
  • src/students/students.service.ts — branch on isCollectionUsage in uploadDocument; reject collection usage in getDocumentUrl / deleteDocument; getDocumentUrlById / deleteDocumentById for the universal by-id paths.
  • src/files/files.service.ts — the helpers above. getSignedUrlByOwner mirrors deleteFileById's ownership resolution before signing.

11.6. Document metadata on the File row

For single-slot files, documentNumber (passport / identity-card number, ≤64 chars) and expiryDate (ISO YYYY-MM-DD) live on the File row itself, not on the owning entity. Two write paths:

  1. Atomic on upload — POST /<entity>/:id/documents accepts documentNumber and expiryDate as multipart form fields alongside file and usage. Persisted on the new File row in the same transaction as the storage put + DB insert.
  2. Standalone PATCH — PATCH /<entity>/:id/documents/by-id/:fileId/metadata takes a { documentNumber?, expiryDate? } body with tri-state semantics (omitted = unchanged, null = clear, string = set).

Both paths are single-slot only. Sending these fields with PERSONAL / EDUCATIONAL rejects with INVALID_USAGE_FOR_SLOT (400), and so does PATCHing metadata on a collection-kind file.

Replacing the file via PATCH /<entity>/:id/documents/:usage does not copy metadata from the previous File row — the new file starts with null metadata. Re-set via the metadata route or pass them again on POST.

12. Student relationships and guardians

School-relationship flags are projections of the current graph. enrichSchoolRelationshipPeople derives sibling and employee-parent evidence using the owning queries and mapper. An admin-set lock makes the stored value authoritative for that flag. Keep the flag and its related-person projection on the same derivation; do not add write-triggered copies of derived truth.

Canonical files: src/students/school-relationships.queries.ts, school-relationship-people.mapper.ts, and students.service.ts.

A Guardian is a non-login, school-owned caregiver profile shared through StudentGuardianLink. Name/contact/private details and the identity document belong to the Guardian; relationship, pickup authorization, and emergency-contact flags belong to each student link.

The routes under /students/:id/guardians support creation, linking an existing guardian, profile/link updates, unlinking, and one identity-document slot. Profile updates affect every linked student; unlinking affects only the selected student. Removing the last link invokes orphan cleanup, including deferred blob deletion.

Reads require the student's referents_and_guardians scope and StudentsReadPolicy. Mutations, candidates, and document routes use GuardiansWritePolicy, which admits admins and linked referents; referent writes also require the link's canWrite. The candidate policy limits family reuse to guardians of writable linked children. A broad student directory grant is not guardian mutation authority.

The identity-document slot accepts PASSPORT or IDENTITY_CARD. Its number and expiry live on the File and are required at upload. The absence of a document is tracked as incompleteness rather than preventing profile creation. Guardian completeness is a separate non-login subject, not a new person profile or auth role.

Canonical files: src/students/guardians.controller.ts, guardians.service.ts, guardians.policy.ts, guardians.queries.ts, guardian-orphan-sweep.ts, and dto/guardian.dto.ts.


See also: chapter 04 for permission wiring, chapter 06 for error codes and the AppException pattern.

Qualified sub-resource authority

Aggregate and sub-resource services whose target authority depends on the role assignment that supplied a grant must use PermissionsService.resolveAuthorizedRoleSlice; they must not inspect the flattened ctx.parameters beside unioned permissions. Curriculum selection and student placement are the canonical examples. For person documents, keep read and write PersonDocumentConfig objects separate whenever a directory read is broader than mutation reach (TeachersDocumentsReadPolicy versus TeachersWritePolicy).