NestJS – Controllers

July 19, 20268 min readUpdated 9/5/2026

A controller is the only place in a well-organised Nest application that knows anything about HTTP. It maps a request onto a method call and hands back whatever that call returns. Everything about what should happen belongs somewhere else.

That sounds like a platitude until you have a controller with business rules in it, at which point it is the difference between a codebase you can test and one you cannot.

Routing

@Controller('api/v1/auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('register')
  register(@Body() dto: RegisterDto) {
    return this.authService.register(dto)
  }

  @Post('login')
  @HttpCode(HttpStatus.OK)
  login(@Body() dto: LoginDto) {
    return this.authService.login(dto)
  }

  @Get('me')
  @UseGuards(JwtAuthGuard)
  me(@CurrentUser() user: AuthenticatedUser) {
    return user
  }
}

The argument to @Controller is a path prefix; the argument to each method decorator is the rest. @Post('register') inside @Controller('api/v1/auth') is POST /api/v1/auth/register. There is one decorator per HTTP verb — @Get, @Post, @Put, @Patch, @Delete, @Head, @Options — and each takes an optional sub-path.

Ordering matters when paths can overlap. Nest matches in declaration order, so a @Get(':id') written above a @Get('me') will swallow /me and try to look up a project whose id is the string me. Put literal segments before parameterised ones.

Getting at the request

You almost never touch the request object. Parameter decorators pull out the piece you want:

@Patch(':projectId/status')
updateStatus(
  @CurrentUser() user: AuthenticatedUser,
  @Param('projectId', ParseUUIDPipe) projectId: string,
  @Body() dto: UpdateProjectStatusDto,
) {
  return this.projectsService.updateStatus(user, projectId, dto.status)
}

@Body() is the parsed request body, @Param('x') a route parameter, @Query('x') a query-string value, @Headers('x') a header. Each also works with no argument to give you the whole object.

Two things in that signature are worth noticing. @CurrentUser() is not built in — it is a custom decorator this application defines, covered in lesson 9, and it reads the user that a guard put on the request. And ParseUUIDPipe is a pipe attached to one parameter: it rejects a non-UUID with a 400 before the method body runs.

That last one is a small decision with a real payoff. Without it, /api/v1/projects/1/status reaches the database as a perfectly valid string comparison that finds nothing — producing a 404 identical to the one you get for somebody else's project. With it, a malformed id is a 400 that says so, and a 404 always means what it says.

There are @Req() and @Res() decorators for the underlying Express objects. Reaching for them costs you something: take @Res() and Nest stops handling the response for you, so you are now responsible for sending it, and interceptors that expected to see a return value no longer do. Use them when you genuinely need to stream, and not otherwise.

Status codes

Nest answers 200 for everything except POST, which gets 201 Created. That default is right often enough to keep and wrong often enough to notice:

@Post('login')
@HttpCode(HttpStatus.OK)
login(@Body() dto: LoginDto) {
  return this.authService.login(dto)
}

Logging in creates nothing. A client that branches on the status code — and plenty do — would be told a user was just created on every sign-in. One decorator fixes it.

The same reasoning in the other direction gives a delete a 204:

@Delete('me/portfolio/:imageId')
@HttpCode(HttpStatus.NO_CONTENT)
async removePortfolioImage(
  @CurrentUser() user: AuthenticatedUser,
  @Param('imageId', ParseUUIDPipe) imageId: string,
) {
  await this.contractorsService.removePortfolioImage(user, imageId)
}

The image is gone and there is nothing meaningful to return. A 200 with an empty body leaves a client wondering whether it should have parsed something; 204 says there is nothing to parse.

For failures you do not set a status at all — you throw, and an exception filter turns the exception into a response. A service throwing NotFoundException is not reaching into HTTP; it is naming a condition that happens to have an obvious status code.

The return value is the response

Return an object and Nest serialises it to JSON. Return a promise and Nest awaits it first — which is why register above has no async and no await: it returns the service's promise directly and Nest does the rest.

Mark a method async when it needs to await something itself. The delete above does, because it awaits the service call and then deliberately returns nothing.

Serialising what goes out

Returning an entity straight from a controller is the most common way to leak something. This application never does it — every write endpoint returns the result of a mapper instead:

export function toUserDto(user: User) {
  return {
    id: user.publicId,
    email: user.email,
    firstName: user.firstName,
    lastName: user.lastName,
    phone: user.phone,
    role: user.role,
    avatarUrl: user.avatarUrl,
    createdAt: user.createdAt.toISOString(),
  }
}

Three properties fall out of writing it this way. The internal sequential id never leaves the process — the API exposes only the UUID, so nobody can count rows or enumerate records by adding one. passwordHash is not omitted by a rule that could be forgotten; there is simply no path from here that can emit it, because no mapper reads it. And a column added to the entity next month does not silently appear in the API.

Nest also offers ClassSerializerInterceptor with @Exclude() on the entity, which is less code and inverts the default: fields are public unless someone remembered to mark them. For a table with a password hash in it, an explicit allowlist is the safer shape.

Controllers own HTTP; services own the rules

This is the line that decides whether a Nest codebase stays workable, and the cleanest way to see it is a controller that is almost empty:

@Controller('api/v1/quotes')
@UseGuards(JwtAuthGuard, RolesGuard)
export class QuotesController {
  constructor(private readonly quotesService: QuotesService) {}

  @Post()
  @Roles(UserRole.CONTRACTOR)
  create(@CurrentUser() user: AuthenticatedUser, @Body() dto: CreateQuoteDto) {
    return this.quotesService.create(user, dto)
  }
}

Submitting a quote is the most rule-heavy operation in this application. The contractor must work in the project's trade. The project must still be accepting bids. They must not have quoted already. The first quote flips the project from open to quoted, and that has to happen in the same transaction as the insert.

None of it is here. The controller checks that you are signed in and are a contractor — both declaratively — and delegates. Every one of those rules is in the service, where it can be tested without an HTTP request and reused by anything else that needs it.

The test for whether a piece of logic belongs in a controller is simple: would it still be true if this were a CLI command instead of an endpoint? Status codes and parameter parsing would not. "A contractor may only bid in a trade they work in" would.

Class-level decorators

Decorators on the class apply to every method in it:

@Controller('api/v1/contractors')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.CONTRACTOR)
export class ContractorsController {
  constructor(private readonly contractorsService: ContractorsService) {}

  @Patch('me')
  updateProfile(@CurrentUser() user: AuthenticatedUser, @Body() dto: UpdateContractorProfileDto) {
    return this.contractorsService.updateProfile(user, dto)
  }
  // ...
}

Every route in this controller requires a signed-in contractor, stated once. Declaring it per method would work identically until the day someone adds a method and forgets — and a missing authorization check is not a failure you notice, because the endpoint works perfectly.

Method-level decorators override class-level ones where both apply, which is what lets ProjectsController put @UseGuards on the class and a different @Roles on each method.

The route that deliberately has no @Roles

Worth pausing on, because it marks the edge of what a controller can decide:

@Patch(':projectId/status')
updateStatus(
  @CurrentUser() user: AuthenticatedUser,
  @Param('projectId', ParseUUIDPipe) projectId: string,
  @Body() dto: UpdateProjectStatusDto,
) {
  return this.projectsService.updateStatus(user, projectId, dto.status)
}

Both roles use this endpoint. A homeowner cancels; the hired contractor starts and completes. Which of them may make a given move depends on the project's current status and on whether this contractor is the one who actually won the job — facts that live in the database.

A decorator cannot express that, because a decorator is evaluated once at startup and has never seen the row. So the authorization for this route is in the service, which can load it. Trying to force it into @Roles would produce a rule that is either too permissive or wrong.

Why there are no read endpoints here

ProjectsController has three routes and all three are writes. That is not an oversight — reads in this application are served by a GraphQL layer with its own row-level permissions, and the controller says so:

@Controller('api/v1/projects')
@UseGuards(JwtAuthGuard, RolesGuard)
export class ProjectsController {
  constructor(private readonly projectsService: ProjectsService) {}

  @Post()
  @Roles(UserRole.HOMEOWNER)
  create(@CurrentUser() user: AuthenticatedUser, @Body() dto: CreateProjectDto) {
    return this.projectsService.create(user, dto)
  }
  // ...
}

Adding a @Get() would create a second way to read the same rows under a second set of rules — and when two sets of rules cover the same data, the one that leaks is the one nobody was thinking about. Whether or not you use GraphQL, the principle transfers: one path to a piece of data, with the access rules on it, rather than two that have to agree forever.

Versioning the path

Every route here starts api/v1, written into each @Controller prefix. Nest also has first-class versioning — app.enableVersioning() plus @Version('1') — which supports URI, header and media-type versioning and lets two versions of one route coexist.

Putting the version in the prefix is the simpler choice and costs a repeated string. It stops being simpler the moment you actually need v1 and v2 of the same endpoint alive at once, which is the point to switch. There is also app.setGlobalPrefix('api') for the part that never varies.

What a controller should never do

Three habits are worth naming as the wrong way to build one, because each looks reasonable in isolation.

Reading the request object directly. @Req() req followed by req.body.email works, and it opts out of validation, of the typed DTO, and of the ability to call the method in a test without constructing a fake request.

Taking the actor from the body. A homeownerId field in a request body is the shortest path to letting anyone act as anyone else. The actor comes from the verified token, always — which is what @CurrentUser() guarantees by construction.

Catching exceptions to reshape them. A try/catch that turns a service error into a status code is a filter written badly and in the wrong place. Let it propagate.

Next: providers and dependency injection — where that authService in the constructor actually comes from.