Adding a Module
Walks through adding a new feature module end-to-end: backend schema → zod + DTO → service → controller → register → route auth policy → regenerate spec → consume from frontend. The example is a fictional Tasks module with a small CRUD surface (list mine, create, update, delete).
The Items module already in the template (backend/src/items/) follows this exact pattern; read it side-by-side if you get stuck.
1. Define the persistence layer
yarn migrate:make tasks (run from backend/) generates a timestamped Knex migration. Fill it in:
// backend/migrations/<timestamp>_tasks.ts
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('tasks', (t) => {
t.string('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
t.string('title').notNullable();
t.boolean('completed').notNullable().defaultTo(false);
// users.id is varchar (NextAuth adapter ids), not uuid - an FK column
// must match the referenced column's type or the migration fails.
// CASCADE fires on a real DELETE; account deletion ghosts the row instead.
t.string('user_id')
.notNullable()
.references('id')
.inTable('users')
.onDelete('CASCADE');
// created_at, updated_at. Postgres has no ON UPDATE clause, so
// updated_at only gets its DEFAULT now() at insert time. Content
// updates set updated_at: new Date() explicitly, as the service's
// update() below does; a deliberate counter bump like a view count
// does not.
t.timestamps(true, true);
t.index(['user_id']);
t.index(['created_at']);
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable('tasks');
}
Then add the row interface in backend/src/db/types.ts:
export interface TaskRow {
id: string;
title: string;
completed: boolean;
user_id: string;
created_at: Date;
updated_at: Date;
}
Run yarn migrate to apply.
Decide what account deletion does to this table
Account deletion anonymizes the users row and keeps it, so the onDelete('CASCADE') above never fires for one, and UsersService.ghostDelete reaches only the tables it names. Full scope: Architecture. Pick one:
- Nothing personal in here (foreign ids, counters, config). Leave it. The ghosted
usersrow keeps the foreign key resolvable. - It can hold personal data, meaning anything free text a user types. Erase it inside
ghostDelete's transaction, where a failure rolls the whole erasure back rather than leaving it half done.itemsis the worked example.
// backend/src/users/users.service.ts, inside ghostDelete's transaction
await trx('tasks').where({ user_id: userId }).del();
// or, when the row has to stay for counts, the way email_jobs does:
await trx('tasks').where({ user_id: userId }).update({ title: '[deleted]' });
Either way, state the table in backend/src/users/deletion-policy.spec.ts, which fails until every table on disk declares its erasure or a reason it needs none. A red test there means this decision has not been made yet: come back and make it, rather than reverse-engineering the fix from the failure.
2. Define schemas + DTOs
*.schemas.ts is the heart of every feature. It holds the zod schemas, the DTO classes (via nestjs-zod's createZodDto), and the extractor function.
// backend/src/tasks/tasks.schemas.ts
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
import { TaskRow } from 'src/db/types';
// ========================================
// Response schemas
// ========================================
export const TaskSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
userId: z.string(),
createdAt: z.string(),
});
export class TaskDto extends createZodDto(TaskSchema) {}
// Delete responses reuse the shared DeleteResultDto from
// src/common/common.schemas. Don't redeclare it per module.
// ========================================
// Request schemas
// ========================================
export const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
});
export class CreateTaskDto extends createZodDto(CreateTaskSchema) {}
export const UpdateTaskSchema = z
.object({
title: z.string().min(1).max(200).optional(),
completed: z.boolean().optional(),
})
.strict();
export class UpdateTaskDto extends createZodDto(UpdateTaskSchema) {}
// ========================================
// DTO Extractor Function
// ========================================
export const extractTaskDtoFromRow = (row: TaskRow) => ({
id: row.id,
title: row.title,
completed: row.completed,
userId: row.user_id,
createdAt: row.created_at.toISOString(),
});
The global ZodValidationPipe validates every @Body() input: TaskDto against the underlying schema, so bad input becomes a 400 with field-level errors without any work in the controller.
3. Write the service
// backend/src/tasks/tasks.service.ts
import { Inject, Injectable } from '@nestjs/common';
import type { Knex } from 'knex';
import { KNEX_CONNECTION } from 'src/knex/knex.module';
import { TaskRow } from 'src/db/types';
@Injectable()
export class TasksService {
constructor(@Inject(KNEX_CONNECTION) private readonly db: Knex) {}
async findByUser(userId: string): Promise<TaskRow[]> {
return this.db<TaskRow>('tasks')
.where({ user_id: userId })
.orderBy('created_at', 'desc');
}
async findById(id: string): Promise<TaskRow | undefined> {
return this.db<TaskRow>('tasks').where({ id }).first();
}
async create(input: { title: string; userId: string }): Promise<TaskRow> {
const [row] = await this.db<TaskRow>('tasks')
.insert({ title: input.title, user_id: input.userId })
.returning('*');
return row;
}
async update(input: {
id: string;
title?: string;
completed?: boolean;
}): Promise<TaskRow | undefined> {
const { id, ...set } = input;
if (Object.keys(set).length === 0) return this.findById(id);
const [row] = await this.db<TaskRow>('tasks')
.where({ id })
.update({
title: set.title,
completed: set.completed,
updated_at: new Date(),
})
.returning('*');
return row;
}
async delete(id: string): Promise<boolean> {
const n = await this.db<TaskRow>('tasks').where({ id }).delete();
return n > 0;
}
}
4. Write the controller
// backend/src/tasks/tasks.controller.ts
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { TasksService } from './tasks.service';
import {
CreateTaskDto,
TaskDto,
UpdateTaskDto,
extractTaskDtoFromRow,
} from './tasks.schemas';
import { DeleteResultDto } from 'src/common/common.schemas';
import { SessionGuard } from 'src/auth/auth.guard';
import { CurrentUser } from 'src/auth/decorators';
import { UserRow } from 'src/db/types';
@ApiTags('tasks')
@Controller('tasks')
@UseGuards(SessionGuard)
@ApiBearerAuth()
export class TasksController {
constructor(private readonly tasks: TasksService) {}
@Get()
@ApiOperation({ summary: 'List my tasks' })
@ApiOkResponse({ type: TaskDto, isArray: true })
async list(@CurrentUser() user: UserRow): Promise<TaskDto[]> {
const rows = await this.tasks.findByUser(user.id);
return rows.map(extractTaskDtoFromRow);
}
@Post()
@ApiOperation({ summary: 'Create a task' })
@ApiCreatedResponse({ type: TaskDto })
async create(
@Body() body: CreateTaskDto,
@CurrentUser() user: UserRow,
): Promise<TaskDto> {
const row = await this.tasks.create({ title: body.title, userId: user.id });
return extractTaskDtoFromRow(row);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a task' })
@ApiOkResponse({ type: TaskDto })
async update(
@Param('id') id: string,
@Body() body: UpdateTaskDto,
@CurrentUser() user: UserRow,
): Promise<TaskDto> {
const existing = await this.tasks.findById(id);
if (!existing) throw new NotFoundException('Task not found');
if (existing.user_id !== user.id && !user.is_admin) {
throw new ForbiddenException('Not your task');
}
const row = await this.tasks.update({ id, ...body });
if (!row) throw new NotFoundException('Task not found');
return extractTaskDtoFromRow(row);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a task' })
@ApiOkResponse({ type: DeleteResultDto })
async delete(
@Param('id') id: string,
@CurrentUser() user: UserRow,
): Promise<DeleteResultDto> {
const existing = await this.tasks.findById(id);
if (!existing) throw new NotFoundException('Task not found');
if (existing.user_id !== user.id && !user.is_admin) {
throw new ForbiddenException('Not your task');
}
const success = await this.tasks.delete(id);
return { success };
}
}
Guards go by access level: omit @UseGuards for a public route, @UseGuards(SessionGuard) + @ApiBearerAuth() for a logged-in one, @UseGuards(SessionGuard, AdminGuard) for admin. Decorate them per route rather than per class when the surface is mixed, the way items.controller.ts does.
Nest's default success status for POST is 201, with no decorator needed. A POST that creates a persisted resource (create() above) documents that with @ApiCreatedResponse instead of @ApiOkResponse, so the generated spec matches the wire. A POST that performs an action rather than creating a resource (queuing a deletion, minting an external session URL) stays 200: add @HttpCode(HttpStatus.OK) to override Nest's default and keep @ApiOkResponse. Picking one without the other is exactly how the spec drifts from the wire.
For non-trivial query/param shapes (anything beyond a string), define a small zod schema and .safeParse() inline: see blog.controller.ts's tag parameter.
5. Wire the module
// backend/src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { AuthModule } from 'src/auth/auth.module';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [AuthModule],
controllers: [TasksController],
providers: [TasksService],
exports: [TasksService],
})
export class TasksModule {}
KNEX_CONNECTION is provided globally by KnexModule, so no extra imports are needed for data access.
Register in the root module:
// backend/src/app.module.ts
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [
// ... existing modules ...
TasksModule,
],
})
export class AppModule {}
6. Add the route auth policy
backend/src/common/route-auth.spec.ts states every route's auth posture once, in a POLICY table, so an unguarded route fails loudly instead of silently shipping open. A controller with no entry there fails that suite, whether or not its own guards are correct - this step is required, not optional cleanup.
Add an entry to the POLICY array, matching the guards each route actually carries:
// backend/src/common/route-auth.spec.ts
import { TasksController } from '../tasks/tasks.controller';
// ... inside the POLICY array, alongside the other entries:
{
name: 'TasksController',
controller: TasksController,
routes: {
list: SESSION,
create: SESSION,
update: SESSION,
delete: SESSION,
},
},
The same file also hardcodes every controller in a second list, in the all array inside the covers every statically-declared controller test, further down. TasksController has to appear there too, or that test fails on the count alone even though the POLICY entry above is correct:
// backend/src/common/route-auth.spec.ts, inside `covers every statically-declared controller`
const all = [
// ... existing controllers ...
StripeWebhookController,
TasksController,
UsersController,
];
Run yarn test src/common/route-auth.spec.ts to check just this before moving on.
7. Regenerate the OpenAPI spec
The canonical openapi.json at the repository root is the contract: regenerate it after any controller, schema, or DTO change, and never hand-edit it.
cd backend
yarn build
npx ts-node -r tsconfig-paths/register scripts/dump-openapi.ts
# emits openapi.runtime.json in cwd; copy to canonical:
cp openapi.runtime.json ../openapi.json
rm openapi.runtime.json
CI's OpenAPI drift job re-runs the dump and diff -us it against the canonical file, so a stale spec fails the build.
8. Regenerate frontend types
cd frontend
yarn generate-types
This runs openapi-typescript ../openapi.json -o types/api.d.ts, which is also generated and never hand-edited. CI regenerates it and runs git diff --exit-code, so commit it with the change.
Re-export the new DTO types from lib/api.ts so consumers don't dig into components['schemas']:
// frontend/lib/api.ts
type Schemas = components['schemas'];
// ... existing ones ...
export type Task = Schemas['TaskDto'];
9. Consume from the frontend
Read from a Server Component
// app/tasks/page.tsx
import { serverApi } from '@/lib/api';
export default async function Page() {
const client = await serverApi();
const { data, error } = await client.GET('/tasks');
if (error || !data) return <p>Failed to load.</p>;
return (
<ul>
{data.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}
serverApi() reads the NextAuth httpOnly cookie via next/headers and attaches the Bearer header server-side: the token never touches client JS.
Mutate via a Server Action
// app/tasks/actions.ts
'use server';
import { serverApi } from '@/lib/api';
import { revalidatePath } from 'next/cache';
export type CreateTaskState = { error: string | null };
export async function createTaskAction(
_prev: CreateTaskState,
formData: FormData,
): Promise<CreateTaskState> {
const client = await serverApi();
const { error } = await client.POST('/tasks', {
body: { title: String(formData.get('title')) },
});
if (error) return { error: 'Create failed' };
revalidatePath('/tasks');
return { error: null };
}
// app/tasks/NewTaskForm.tsx (client island)
'use client';
import { useActionState } from 'react';
import { createTaskAction } from './actions';
export function NewTaskForm() {
const [state, formAction, pending] = useActionState(createTaskAction, {
error: null,
});
return (
<form action={formAction}>
<input name="title" required />
<button type="submit" disabled={pending}>
Add
</button>
{state.error ? <p role="alert">{state.error}</p> : null}
</form>
);
}
openapi-fetch types both halves: body is checked against CreateTaskSchema, data is the typed TaskDto.
Checklist
- Knex migration + row interface in
db/types.ts - Table added to
DELETION_POLICYinsrc/users/deletion-policy.spec.ts, with its erasure wired intoUsersService.ghostDeleteand asserted inusers.service.integration.spec.ts, or a reason it needs none -
*.schemas.tswith zod schemas, DTO classes, extractor -
*.service.tswith data access -
*.controller.tswith REST decorators, guards,@ApiOkResponse(@ApiCreatedResponsefor aPOSTthat creates a resource) -
*.module.tsregistering controller/service - Module added to
app.module.ts - Routes added to the
POLICYtable insrc/common/route-auth.spec.ts, and to theallarray in itscovers every statically-declared controllertest -
yarn build && npx ts-node -r tsconfig-paths/register scripts/dump-openapi.ts, copied to the repository-rootopenapi.json -
yarn generate-typesinfrontend/ - Frontend consumer (RSC + Server Action) wired
-
./verify.shgreen
Related
- Architecture - Module structure overview, REST surface, DTO extractor pattern
- Auth Flow - Guard reference, RSC + Server Action patterns
- Home - Docs index