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.

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. Maps 1:1 to a future microservice.
  • Barrel exports — every module has index.ts. Import from the folder ('../students'), never from internal files ('../students/students.service').
  • Do not import services across modules — export the service from the module, then import the module in AppModule. Never call a foreign service directly.
  • 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.

interfaces/ convention

What goes in interfaces/: - Prisma payload typesPrisma.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

@ApiStudentsController()        // class-level Swagger (tag, error models)
@Controller('students')
@ProtectedResource()            // composes JwtAuthGuard + ScopeGuard + ActionGuard +
                                // FieldWriteGuard + FieldFilterInterceptor + ApiBearerAuth
export class StudentsController {
  constructor(private readonly studentsService: StudentsService) {}

  /** Create a student */
  @Post()
  @RequireAction(EntityKey.STUDENTS, 'create')
  @ApiCreateStudent()
  async create(
    @Body() dto: CreateStudentDto,
    @TenantId() tenantId: string,
  ): Promise<StudentResponseDto> {
    return this.studentsService.create(tenantId, dto);
  }

  /** List students */
  @Get()
  @RequireScopes(EntityKey.STUDENTS, 'read')
  @ApiListStudents()
  async findAll(
    @Query() query: AcademicYearPaginationQueryDto,
    @TenantId() tenantId: string,
  ): Promise<PaginatedResponseDto<StudentResponseDto>> {
    return this.studentsService.findAll(query, tenantId);
  }

  /** Get a student by ID */
  @Get(':id')
  @RequireScopes(EntityKey.STUDENTS, 'read')
  @ApiGetStudent()
  async findOne(
    @Param('id', ParseUUIDPipe) id: string,
    @TenantId() tenantId: string,
  ): Promise<StudentResponseDto> {
    return this.studentsService.findOne(id, tenantId);
  }

  /** Update a student */
  @Patch(':id')
  @RequireScopes(EntityKey.STUDENTS, 'write')
  @ApiUpdateStudent()
  async update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateStudentDto,
    @TenantId() tenantId: string,
  ): Promise<StudentResponseDto> {
    return this.studentsService.update(id, dto, tenantId);
  }

  /** Delete a student */
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  @RequireAction(EntityKey.STUDENTS, 'delete')
  @ApiDeleteStudent()
  async remove(
    @Param('id', ParseUUIDPipe) id: string,
    @TenantId() tenantId: string,
  ): Promise<void> {
    return this.studentsService.remove(id, tenantId);
  }
}

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 — never accept raw strings for IDs.
  • JSDoc /** */ on controller methods is public API documentationnest-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).

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


3. Service Recipe

All domain services extend BaseTenantedCrudService, which 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,
  ) {
    super(prisma, customFieldsService, EntityKey.STUDENTS, 'Student');
  }

  /** Which Prisma delegate to use (supports transaction clients) */
  protected getPrismaDelegate(client?: any) {
    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 {
      anagraphic: [...STUDENT_SCOPES.anagraphic],   // array = simple field list
      enrollment: (r) => ({                          // function = custom shape
        enrollmentDate: r.enrollmentDate,
        department: r.department,                    // resolved relation
        grade: r.grade,
      }),
    };
  }

  // Optional lifecycle hooks
  protected async beforeCreate(
    ctx: CreateContext<CreateStudentDto>,
  ): Promise<void> {
    // Validate, resolve FK IDs, detect duplicates
  }

  protected async afterCreate(
    record: StudentWithRelations,
    ctx: CreateContext<CreateStudentDto>,
  ): Promise<void> {
    // Post-create side effects (logging, notifications)
  }
}

Base service internals

Dynamic scope auto-discoverytoScopedResponse() 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.

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.

Universal academic year scopingfindAll() accepts an optional academicYearId (reads can target any non-DELETED year). Writes (create, update, remove, reorder) never accept an override — they always resolve to the tenant's ACTIVE year via resolveActiveYear(tenantId). You cannot create or modify records belonging to a past or draft year. assertYearWritable() is called automatically in create(), update(), and remove() — it blocks mutations on ARCHIVED years (defence-in-depth on the update/remove paths, redundant on create). The create lifecycle order is: resolveActiveYearassertYearWritablebeforeCreate. Hooks can rely on ctx.academicYearId being set.

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

Duplicate detection scope — checks in beforeCreate hooks and import checkDuplicates callbacks must be scoped to the target year. The same personUuid may appear across years; only same-year duplicates are invalid.

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


4. DTOs & Validation

Scope sub-DTO pattern

One file per scope under dto/scopes/. Each file contains three classes:

// dto/scopes/student-anagraphic.dto.ts
export class CreateStudentAnagraphicDto {
  @IsString() @MaxLength(100)
  @ApiProperty({ maxLength: 100 })
  firstName: string;

  @IsDate() @Type(() => Date)
  @ApiProperty({ format: 'date' })
  dateOfBirth: Date;

  @IsOptional() @IsEnum(Gender)
  @ApiPropertyOptional()
  gender?: Gender;

  @IsOptional() @IsObject()
  @ApiPropertyOptional()
  customFields?: Record<string, unknown>;
}

export class UpdateStudentAnagraphicDto extends PartialType(CreateStudentAnagraphicDto) {}

export class StudentAnagraphicResponseDto {
  @ApiProperty() firstName: string;
  @ApiProperty({ format: 'date-time' }) dateOfBirth: Date;
  @ApiPropertyOptional({ nullable: true }) gender: Gender | null;
  @ApiPropertyOptional({ type: 'object' }) customFields?: Record<string, CustomFieldValueDto>;
}

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(() => CreateStudentAnagraphicDto)
  anagraphic: CreateStudentAnagraphicDto;       // required scope

  @IsOptional() @ValidateNested() @Type(() => CreateStudentSensitiveDto)
  sensitive?: CreateStudentSensitiveDto;         // optional scope
}

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

  @IsOptional() @ValidateNested() @Type(() => UpdateStudentSensitiveDto)
  sensitive?: UpdateStudentSensitiveDto;
}

Response shape

// dto/student-response.dto.ts
export class StudentResponseDto {
  id: string;
  personUuid: string;
  academicYearId: string;
  anagraphic?: StudentAnagraphicResponseDto;     // all scopes optional —
  sensitive?: StudentSensitiveResponseDto;        // 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). Kept for endpoints that don't need filtering yet (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).

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 getBaseWhere(), 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-gating-by-inclusion. A v1 ListXQueryDto only declares filter fields whose visibility scope every caller of the route already has — there is no per-field @RequiresScope mechanism. If a sensitive-scope filter ever lands (admin-only medicalNotesContains), introduce a separate admin-only DTO behind a guard rather than tagging individual 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. The convention is additive: modules with no extra filters can keep binding 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, StudentAnagraphicResponseDto] });

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

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, unique constraint (has params)
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.tsApiOperation({ 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 after cleanup: 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 pure functions accepting PrismaService as first param — compatible with transaction clients
  • No business logic, no exception throwing — just data loading
  • 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 intentfindGradeIdsByTenantAndYear, not getGrades

Condensed example

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

// Payload type lives in interfaces/, not here
// export type StudentWithRelations = Prisma.StudentGetPayload<{ include: typeof studentQueryInclude }>;

export async function loadImportLookups(
  prisma: PrismaService,
  tenantId: string,
  academicYearId: string,
): Promise<ImportLookupData> {
  // pure data fetch — no exceptions, no business logic
}

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

Existing query files

Style Modules
Constants only auth.queries.ts, users.queries.ts, custom-fields.queries.ts
Constants + functions permissions.queries.ts, students.queries.ts, teachers.queries.ts, staff.queries.ts, departments.queries.ts, rooms.queries.ts

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


7. Nested Sub-Resources

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

Controller

@ApiGradesController()
@ApiParam({ name: 'departmentId', format: 'uuid' })   // class-level — applies to all routes
@Controller('departments/:departmentId/grades')
@ProtectedResource()
export class GradesController {
  /** Create a grade within a department */
  @Post()
  @RequireAction(EntityKey.GRADES, 'create')
  async create(
    @Param('departmentId', ParseUUIDPipe) departmentId: string,
    @Body() dto: CreateGradeDto,
    @TenantId() tenantId: string,
  ): Promise<GradeResponseDto> {
    return this.gradesService.create(departmentId, dto, tenantId);
  }

  /** List grades for a department */
  @Get()
  @RequireScopes(EntityKey.GRADES, 'read')
  async findAll(
    @Param('departmentId', ParseUUIDPipe) departmentId: string,
    @TenantId() tenantId: string,
  ): Promise<GradeResponseDto[]> {
    return this.gradesService.findAll(departmentId, tenantId);
  }
}

Service

Service methods accept parentId as the first param and filter by both tenantId and parentId:

async create(departmentId: string, dto: CreateGradeDto, tenantId: string) {
  // 1. Validate parent exists and belongs to tenant
  const dept = await this.prisma.department.findFirst({
    where: { id: departmentId, tenantId },
  });
  if (!dept) throw new AppException(ErrorCode.NOT_FOUND, 'Department not found', HttpStatus.NOT_FOUND);

  // 2. Create child scoped to both tenant and parent
  return this.prisma.grade.create({
    data: { ...dto.configuration, departmentId, tenantId },
  });
}

Permission setup

The child entity gets its own EntityKey, PermissionEntity, PermissionScope(s), and PermissionAction(s) in the seed — it is never piggy-backed on the parent's permissions.

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 to their linked kids by studentsForAccessContext), not through GET /referents/me/students. The old nested-route pattern is gone because it multiplied surfaces, split scope evaluation across entities, and made role-specific routing implicit.


8. Reorder Endpoints

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

Route declaration (must be before PATCH :id)

/** Reorder departments */
@Patch('reorder')                                  // declared BEFORE @Patch(':id')
@RequireScopes(EntityKey.DEPARTMENTS, 'write')     // not @RequireAction
@ApiReorderDepartments()
async reorder(
  @Body() dto: ReorderDepartmentsDto,
  @TenantId() tenantId: string,
): Promise<void> {
  return this.departmentsService.reorder(tenantId, dto.configuration.items);
}

/** Update a department */
@Patch(':id')                                      // comes after reorder
@RequireScopes(EntityKey.DEPARTMENTS, 'write')
async update(...) { ... }

Route ordering caveat: PATCH reorder must be declared before PATCH :id in the controller class. NestJS matches routes in declaration order — if :id comes first, the literal string reorder is incorrectly treated as an ID.

Service

async reorder(tenantId: string, items: ReorderItemDto[]): Promise<void> {
  // 1. Validate all IDs belong to this tenant
  const ids = items.map((i) => i.id);
  const existing = await this.prisma.department.findMany({
    where: { id: { in: ids }, tenantId },
    select: { id: true },
  });
  if (existing.length !== ids.length) {
    throw new AppException(ErrorCode.NOT_FOUND, 'One or more items not found', HttpStatus.NOT_FOUND);
  }

  // 2. Run all ordinal updates in a single transaction
  await this.prisma.$transaction(
    items.map((item) =>
      this.prisma.department.update({
        where: { id: item.id },
        data: { ordinalPosition: item.ordinalPosition },
      }),
    ),
  );
}

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.

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


10. Code Gotchas

Dates are Date objects

DTOs use @IsDate() @Type(() => Date) from class-transformer. Never use new Date() string parsing in services, never call .toDateString() or manual format helpers in response mappers. The JSON serializer auto-converts Date → ISO string on the way out.

// CORRECT
@IsDate() @Type(() => Date)
dateOfBirth: Date;

// WRONG — strings bypass class-transformer
@IsString()
dateOfBirth: string;

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 frontend i18n keying. See chapter 06.

// CORRECT
throw new AppException(ErrorCode.NOT_FOUND, 'Student not found', HttpStatus.NOT_FOUND);

// 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';

@nestjs/jwt expiresIn type mismatch

expiresIn expects StringValue from the ms package, not string. Use an as any cast:

expiresIn: this.config.getOrThrow('JWT_EXPIRY') 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 files need /* eslint-disable */

Mocked objects trigger no-unsafe-* ESLint rules. Add the disable comment at the top of every *.spec.ts file:

/* eslint-disable */

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 has four values today, split by cardinality:

Cardinality Usages Storage Per-person count
Single-slot PASSPORT, IDENTITY_CARD FK column on the owning entity (passportFileId, identityCardFileId) exactly one
Collection PERSONAL, EDUCATIONAL File row tagged with (ownerType: FileOwnerType, ownerId) — same polymorphic pattern Invitation uses many

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 appendCollectionFile / appendCollectionFiles getSignedUrlByOwner
clearFileSlot deleteFileById
updateFileMetadataById listFilesForEntity (merges both)
cleanupCollectionForOwner (entity hard-delete)

appendCollectionFiles is the all-or-nothing N-file helper used by the multi-file POST path; appendCollectionFile is a thin wrapper kept for existing single-payload call sites.

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 only put, getSignedReadUrl, and delete. Reads are always pre-signed URLs — there is no streaming path through the application. 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.

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

11.7. Spec history

The original Files MVP spec (docs/superpowers/specs/2026-04-28-files-mvp-design.md) shipped only single-slot with backend-streamed reads. Three follow-on changes have landed since:

  1. Collection support (PERSONAL, EDUCATIONAL) using the polymorphic ownerType + ownerId pattern the spec deferred.
  2. Signed-URL readsGET endpoints now mint pre-signed URLs and return JSON envelopes; the application is no longer in the byte path. TTL is configurable via S3_SIGNED_URL_TTL_SECONDS (default 15 min).
  3. Document metadata on the File row (2026-05-07). documentNumber and expiryDate for passport / identity card moved off the owning entity onto File. The 24 per-entity metadata columns were dropped in the same release, including all passportIssueDate / identityCardIssueDate columns (issue dates are no longer tracked). Document-completeness rules now key on passportFileId / identityCardFileId only — the file's existence gates completeness.

The remaining deferred features in §13 of that doc (virus scanning, soft delete, hashing) are still deferred.


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