Add a Resource Module
A step-by-step tutorial for building a brand-new tenant-scoped resource end to end, from entity to migration.
Sooner or later you'll need a whole new resource — its own database table and its own CRUD API. On this backend that's not a single file; it's a small stack of files that each do one job, wired together by a NestJS module. The good news: every resource follows the exact same shape, so once you've built one you can build any of them.
Here's the mental model. A request flows down through clearly separated layers — the controller handles HTTP, the service holds business logic, the repository talks to the database, and the entity maps to a table. DTOs describe what goes in and what comes out. You'll build them bottom-up and let a module stitch them together.
DTO ←→ Controller → Service → Repository → Entity → (table)
↑
BaseRepository<T> (generic CRUD from @app/shared)We'll build a fictional Announcement resource — a short message, optionally tied to a campus. The simplest real module to read alongside this page is Campus at apps/server/src/app/campus/; keep it open in a tab.
Tenant-scoped is the default here
Almost every resource is owned by a school (a tenant). You declare that with one decorator and one column, and the framework auto-filters every read and stamps the owning school onto every write — your service writes zero scoping code. We'll lean on that throughout. Module Anatomy covers the layered shape in more depth.
Step 1: Branch and plan the shape
Start on a fresh branch off staging so your work is isolated:
git switch staging && git pull
git switch -c feat/announcementsBefore any classes, write down the shape of the data as a plain TypeScript interface. The entity will later implement this, which keeps your domain type and your table definition honest with each other. It lives in the module's dto/ folder.
import type { DatabaseEntity } from '@app/shared';
export interface Announcement extends DatabaseEntity {
title: string;
body: string;
campusId?: string;
}Extending DatabaseEntity means an Announcement automatically carries the standard fields every record has — a UUID id and timestamps — so you only list the fields unique to this resource.
Step 2: Define the entity
The entity is a TypeORM class that maps to a database table. Each @Column becomes a column; the class extending DatabaseEntity inherits the UUID primary key and soft-delete plumbing. This is also where you opt the resource into tenant scoping.
import { DatabaseEntity, TenantScoped, WithTimestamps } from '@app/shared';
import { Column, Entity } from 'typeorm';
import type { Announcement } from '../dto/announcement.types';
@Entity('announcement')
@TenantScoped() // tenant-owned → auto-scoped by school_id
@WithTimestamps()
export class AnnouncementEntity extends DatabaseEntity implements Announcement {
@Column({ name: 'school_id', type: 'uuid', nullable: true }) schoolId?: string;
@Column() title: string;
@Column({ type: 'text' }) body: string;
@Column({ name: 'campus_id', type: 'uuid', nullable: true }) campusId?: string;
}A few things are doing real work here. @WithTimestamps() adds created_at, updated_at, and deleted_at (that last one powers soft delete). Column DB names are snake_case even though the TypeScript properties are camelCase — note name: 'campus_id'.
Two pieces make a resource tenant-scoped
@TenantScoped() and a school_id column together flip on automatic scoping. Once both are present, BaseRepository silently filters reads to the current school and stamps school_id on writes — fail-closed, so you can't accidentally leak across tenants. Omit both only for genuinely global tables. If you add the decorator but forget the column (or vice versa), scoping won't behave — they're a pair.
Step 3: Add the repository
The repository is your data-access layer, and it's deliberately thin. By extending BaseRepository you inherit a full set of CRUD methods for free — there's nothing to write but the constructor, which tells the base class which entity it manages.
import { BaseRepository } from '@app/shared';
import { Injectable } from '@nestjs/common';
import { AnnouncementEntity } from '../entities/announcement.entity';
@Injectable()
export class AnnouncementRepository extends BaseRepository<AnnouncementEntity> {
constructor() { super(AnnouncementEntity); }
}Resist the urge to add query methods here. BaseRepository already gives you create, findById, findAll, update, softDelete, cursorPaginate, and more — and it's the layer that applies tenant scoping. Keep custom queries out unless you genuinely need one. The Database goes deeper on what BaseRepository provides.
Step 4: Write the DTOs
DTOs (Data Transfer Objects) are the contract at the edge of your API. They do two jobs at once: class-validator decorators validate incoming data, and @ApiProperty decorators document the shape in the generated OpenAPI spec. You write two — one for input, one for output.
The input DTO describes what a client may send. It omits server-controlled fields like id and timestamps, and validates every property.
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateAnnouncementDto {
@IsString() @ApiProperty({ example: 'Term starts Monday' }) title: string;
@IsString() @ApiProperty({ example: 'School resumes on…' }) body: string;
@IsOptional() @IsUUID() @ApiPropertyOptional() campusId?: string;
}The response DTO (announcement.dto.ts) carries the same fields plus extends DatabaseEntityDto, which contributes the documented id and timestamp fields — so you declare only the resource's own properties on top.
Don't over-describe self-explanatory fields
Follow the house @ApiProperty rule: skip the description option when the field name already says it all (title, body). Reach for example or description only when they add information a reader wouldn't guess. The full DTO conventions live in API Conventions.
Step 5: Build the service
The service is where business logic lives. It depends on the repository (injected via the constructor) and exposes intent-named methods. Keep it free of HTTP concerns — no request objects, no status codes — and let it throw domain exceptions like NotFoundException when something's wrong.
import { Injectable, NotFoundException } from '@nestjs/common';
import { AnnouncementRepository } from './repositories/announcement.repository';
import { CreateAnnouncementDto } from './dto/create-announcement.dto';
@Injectable()
export class AnnouncementService {
constructor(private readonly repo: AnnouncementRepository) {}
create(dto: CreateAnnouncementDto) { return this.repo.create(dto); }
list() { return this.repo.findAll({ order: { createdAt: 'DESC' } }); }
async findOne(id: string) {
const found = await this.repo.findById(id);
if (!found) throw new NotFoundException('Announcement not found');
return found;
}
}Notice the check-then-act pattern in findOne — look the record up, and if it's missing, throw rather than returning null. Notice too that nothing here mentions a school: because the entity is @TenantScoped(), the repository already constrained findAll and findById to the caller's tenant.
Campus and year scoping need explicit checks
Tenant scoping is automatic, but campus scoping is not — if a resource should be visible only within certain campuses, use PermissionScopeService the way CampusService does (see Auth & Permissions). And if the data belongs to one academic year (a trip happens in a year), it's not this recipe you want — follow Add a Year-Scoped Entity, which layers the required campusAcademicYearId and lock gates on top of these same steps.
Step 6: Expose the controller
The controller maps HTTP routes to service calls and nothing more. Every route carries three kinds of decorator: a permission guard (@RequirePermissions), Swagger metadata (@ApiOperation with an operationId), and a typed @ApiResponse that points at your response DTO so the generated client knows the return shape.
@Controller('announcements')
@ApiTags('Announcements')
@ApiBearerAuth()
@ApiAuthErrorResponses()
export class AnnouncementController {
constructor(private readonly service: AnnouncementService) {}
@RequirePermissions({ action: Action.CREATE, resource: Resource.WEBSITE_FORM })
@Post()
@ApiOperation({ summary: 'Create Announcement', operationId: 'createAnnouncement',
description: 'Create a new announcement, optionally scoped to a campus.' })
@ApiResponse({ status: 201, description: 'Announcement created.', type: AnnouncementDto })
create(@Body() dto: CreateAnnouncementDto) { return this.service.create(dto); }
@RequirePermissions({ action: Action.READ, resource: Resource.WEBSITE_FORM })
@Get()
@ApiOperation({ summary: 'List Announcements', operationId: 'listAnnouncements',
description: 'List announcements newest first.' })
@ApiResponse({ status: 200, description: 'Announcements listed.', type: [AnnouncementDto] })
list() { return this.service.list(); }
}The operationId (createAnnouncement, listAnnouncements) becomes the method name in the generated frontend client, so keep it camelCase and verb-first. Keep summary to three words or fewer. The class-level @ApiAuthErrorResponses() documents the shared 401/403 responses once for every route.
Pick a real Resource — and add one only if you must
The resource in @RequirePermissions must be a real value from the permission enum. If your new resource has no fitting Resource, you'll need to add one in libs/shared/src/permission/permission.enum.ts and wire it into the CASL ability factory — that's a deliberate, tracked change, not a quick edit. Reuse an existing value where it genuinely fits.
Step 7: Wire it up
A NestJS module is the glue. It registers the entity with TypeORM, declares the repository and service as providers (so NestJS can inject them), lists the controller, and exports anything other modules might need.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AnnouncementEntity } from './entities/announcement.entity';
import { AnnouncementRepository } from './repositories/announcement.repository';
import { AnnouncementService } from './announcement.service';
import { AnnouncementController } from './announcement.controller';
@Module({
imports: [TypeOrmModule.forFeature([AnnouncementEntity])],
controllers: [AnnouncementController],
providers: [AnnouncementRepository, AnnouncementService],
exports: [AnnouncementService],
})
export class AnnouncementModule {}The TypeOrmModule.forFeature([AnnouncementEntity]) line is what makes the entity injectable into the repository — forget it and you'll get a cryptic "no repository for AnnouncementEntity" error at boot.
A module isn't live until the root module imports it. Finish by adding AnnouncementModule to the imports array in apps/server/src/app/app.module.ts — one line, and NestJS bootstraps your controller, service, and repository on startup.
@Module({
imports: [
// ...existing modules
AnnouncementModule,
],
})
export class AppModule {}Step 8: Generate the migration
Your entity describes a table that doesn't exist yet, and the schema doesn't change itself. Generating and running migrations is a deliberate, developer-run step with its own gotchas — follow Add an Entity & Migration for the full procedure. The two commands you'll use:
pnpm migration:generate add-announcement
pnpm migration:runNo migration, no table
The app will start fine with a missing table, then every query against announcement fails at runtime. Always generate and run the migration before you consider the resource done — and commit the migration file alongside your code.
Step 9: Test and verify
Finish by proving it works. Add a co-located spec under tests/, then run the suite and the linter, and boot the app to confirm your new operations show up in the live API docs.
pnpm test
pnpm lint
pnpm start:dev # then open /docs and find createAnnouncement / listAnnouncementsSeeing createAnnouncement and listAnnouncements in the Swagger UI at /docs is your end-to-end confirmation: the controller, DTOs, and permissions all registered correctly. Testing covers how to write the service spec.
Recap
Bottom-up, every time
Type → entity → repository → DTOs → service → controller → module → app.module → migration.
Scoping is mostly free
@TenantScoped() plus a school_id column, and BaseRepository handles the rest.
The module is glue
forFeature the entity, declare providers, list the controller, register in app.module.ts.
Migrations are manual
The entity defines the table; you generate and run the migration yourself.