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
AppModulelean — only module imports, no business logic. - DTOs in
dto/subfolder — scope sub-DTOs indto/scopes/. UsePartialType()/OmitType()from@nestjs/mapped-typesfor Update DTOs. - Interfaces in
interfaces/subfolder — types that never leave the backend. Eachinterfaces/folder has its ownindex.tsbarrel.
interfaces/ convention¶
What goes in interfaces/:
- 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¶
@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 documentation —nest-cli.jsonenablesintrospectComments: 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
@RequireScopesfor GET/PATCH,@RequireActionfor 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-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.
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 scoping — findAll() 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: resolveActiveYear → assertYearWritable → beforeCreate. 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/PaginationQueryDtodirectly. - Filters or sort → declare
ListXQueryDto extends PaginatedListQueryDto(orBasicPaginatedListQueryDto) insrc/<domain>/dto/list-<domain>-query.dto.ts, plusbuildListArgsForX(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.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.tsentry already setsApiOperation({ 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-validatorconstraint (@IsEmaildescription "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
PrismaServiceas first param — compatible with transaction clients - No business logic, no exception throwing — just data loading
- Include constants use
as const, derive payload types viaPrisma.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.tsis not exported from the moduleindex.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, notgetGrades
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 reordermust be declared beforePATCH :idin the controller class. NestJS matches routes in declaration order — if:idcomes first, the literal stringreorderis 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 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:
@nestjs/jwt expiresIn type mismatch¶
expiresIn expects StringValue from the ms package, not string. Use an as any cast:
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:
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 +assertReferentCanWritegate).src/students/students.service.ts— branch onisCollectionUsageinuploadDocument; reject collection usage ingetDocumentUrl/deleteDocument;getDocumentUrlById/deleteDocumentByIdfor the universalby-idpaths.src/files/files.service.ts— the helpers above.getSignedUrlByOwnermirrorsdeleteFileById'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:
- Atomic on upload — POST
/<entity>/:id/documentsacceptsdocumentNumberandexpiryDateas multipart form fields alongsidefileandusage. Persisted on the new File row in the same transaction as the storage put + DB insert. - Standalone PATCH —
PATCH /<entity>/:id/documents/by-id/:fileId/metadatatakes 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:
- Collection support (
PERSONAL,EDUCATIONAL) using the polymorphicownerType+ownerIdpattern the spec deferred. - Signed-URL reads —
GETendpoints now mint pre-signed URLs and return JSON envelopes; the application is no longer in the byte path. TTL is configurable viaS3_SIGNED_URL_TTL_SECONDS(default 15 min). - Document metadata on the File row (2026-05-07).
documentNumberandexpiryDatefor passport / identity card moved off the owning entity ontoFile. The 24 per-entity metadata columns were dropped in the same release, including allpassportIssueDate/identityCardIssueDatecolumns (issue dates are no longer tracked). Document-completeness rules now key onpassportFileId/identityCardFileIdonly — 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.