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.