Foundations View as Markdown

Controllers and routing

Parse the request, call a service, return a value. Everything else in a controller is a mistake.

A controller maps HTTP to method calls. The discipline that keeps a Nest codebase healthy is that it does nothing else.

src/users/users.controller.ts
import {
  Controller, Get, Post, Patch, Delete,
  Param, Query, Body, HttpCode, ParseIntPipe,
} from '@nestjs/common';

@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get()
  findAll(@Query('limit', ParseIntPipe) limit = 20) {
    return this.users.findAll(limit);
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.users.findOne(id);
  }

  @Post()
  @HttpCode(201)
  create(@Body() dto: CreateUserDto) {
    return this.users.create(dto);
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
    return this.users.update(id, dto);
  }

  @Delete(':id')
  @HttpCode(204)
  remove(@Param('id') id: string) {
    return this.users.remove(id);
  }
}

Every method is one line. That is the target.

Return values#

Return an object and Nest serialises it as JSON with a 200 (or 201 for @Post). Return a promise and it awaits it. Return an Observable and it subscribes.

You almost never need the raw response object. If you take @Res(), you opt out of Nest's serialisation entirely and become responsible for ending the response yourself — a common source of hung requests.

ts
// avoid unless you genuinely need streaming or a redirect
@Get('download')
download(@Res() res: Response) {
  res.setHeader('Content-Type', 'text/csv');
  res.send(csv);           // you must end it. Nest will not.
}

Use @Res({ passthrough: true }) when you only need to set a header or a cookie and still want Nest to serialise the return value.

Route parameters and order#

ts
@Get('me')            // must come before ':id'
me() {}

@Get(':id')
findOne(@Param('id') id: string) {}

Routes match in declaration order. @Get(':id') declared first will swallow /users/me. This is one of the two or three most common Nest bugs.

Status codes and headers#

ts
@Post()
@HttpCode(201)
@Header('Cache-Control', 'no-store')
create(@Body() dto: CreateUserDto) {}

@Get('old')
@Redirect('/users', 301)
old() {}

Sub-resources#

ts
@Controller('users/:userId/orders')
export class UserOrdersController {
  @Get()
  list(@Param('userId') userId: string) {
    return this.orders.forUser(userId);
  }
}

Common questions#

Should validation happen in the controller?#

Declare it — with a DTO class and decorators — and let the global ValidationPipe enforce it. That is the controller declaring its contract, not implementing logic. Business rules ("this email is already taken") belong in the service.

Where does error handling go?#

Throw a Nest exception (NotFoundException, ForbiddenException) from the service, and let the built-in exception filter turn it into a response. Controllers should not contain try/catch in the normal case.

Why is my :id route catching everything?#

Because a more specific literal route is declared after it. Move static segments (me, search, export) above parameterised ones.

Get the NestJS agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for NestJS. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.