Controllers and routing
A controller maps HTTP requests to methods. Its job is narrow on purpose: read the request, call a service, return the result. Everything else belongs somewhere else.
A controller
// apps/api/src/products/products.controller.ts
import { Controller, Get, Param, Query } from "@nestjs/common";
import { ProductsService } from "./products.service";
@Controller("products")
export class ProductsController {
constructor(private readonly products: ProductsService) {}
@Get()
findAll(@Query("category") category?: string) {
return this.products.findAll({ category });
}
@Get(":slug")
findOne(@Param("slug") slug: string) {
return this.products.findBySlug(slug);
}
}
With the api global prefix from module 1, those are GET /api/products and
GET /api/products/atta-5kg.
@Controller("products") is the path prefix. Method decorators append to
it, so @Get(":slug") is products/:slug.
Return the value; do not touch the response object. NestJS serialises
whatever you return to JSON and sends it. Reaching for @Res() gives you an
Express response and turns off that handling, which loses interceptors and
serialisation. You almost never need it.
Async is the same — return the promise:
@Get()
async findAll() {
return this.products.findAll();
}
The method decorators
| Decorator | Method |
|---|---|
@Get(path?) |
GET |
@Post(path?) |
POST |
@Patch(path?) |
PATCH |
@Put(path?) |
PUT |
@Delete(path?) |
DELETE |
Status codes default sensibly: 200 for everything except @Post, which returns
201. Override when needed:
@Delete(":id")
@HttpCode(204)
remove(@Param("id") id: string) {
return this.products.remove(id);
}
204 means "success, no body" — the right answer for a delete, from the HTTP lesson.
Reading the request
| Decorator | Gets | Example |
|---|---|---|
@Param("id") |
a route parameter | /products/42 |
@Query("page") |
a query string value | ?page=2 |
@Body() |
the parsed JSON body | |
@Headers("authorization") |
one header | |
@Req() |
the raw request | avoid |
@Post()
create(@Body() dto: CreateProductDto) {
return this.products.create(dto);
}
@Patch(":id")
update(@Param("id") id: string, @Body() dto: UpdateProductDto) {
return this.products.update(id, dto);
}
Without an argument, @Query() and @Param() give the whole object — which is
what you want once there are several:
@Get()
findAll(@Query() query: QueryProductsDto) {
return this.products.findAll(query);
}
Every value from a URL is a string. @Query("page") page is "2", not
2. Pipes convert, and that is the next lesson but one — for now, know that
page * 10 gives you "2222222222" if you forget.
Route order matters
@Get(":slug")
findOne(@Param("slug") slug: string) { ... }
@Get("featured")
findFeatured() { ... } // unreachable
GET /products/featured matches :slug first, and findFeatured never runs.
Routes are matched in declaration order, so specific routes must come before
dynamic ones:
@Get("featured")
findFeatured() { ... }
@Get(":slug")
findOne(@Param("slug") slug: string) { ... }
Symptom when you get it wrong: an endpoint that returns "not found" for a
perfectly valid hard-coded path. It is the same ordering problem as an elif
chain.
Keep controllers thin
@Post()
async create(@Body() dto: CreateOrderDto) {
const products = await this.prisma.product.findMany({
where: { id: { in: dto.items.map((i) => i.productId) } },
});
let total = 0;
for (const item of dto.items) {
const product = products.find((p) => p.id === item.productId);
if (!product) throw new BadRequestException("Unknown product");
if (product.stock < item.quantity) throw new ConflictException("Out of stock");
total += product.pricePaise * item.quantity;
}
// thirty more lines
}
Everything wrong with the Express example from the last lesson, in NestJS syntax. The structure does not help if you ignore it.
@Post()
create(@CurrentUser() user: User, @Body() dto: CreateOrderDto) {
return this.orders.create(user.id, dto);
}
A controller method should be one to three lines. Longer means logic has leaked in.
The test: could this logic be needed outside an HTTP request? Creating an order might come from a scheduled reorder or an admin script. Logic in a controller cannot be reused; logic in a service can.
Errors
Throw NestJS's exceptions and a built-in filter turns them into responses:
import { NotFoundException, BadRequestException, ConflictException } from "@nestjs/common";
const product = await this.prisma.product.findUnique({ where: { slug } });
if (!product) {
throw new NotFoundException(`No product with slug "${slug}"`);
}
{ "statusCode": 404, "message": "No product with slug \"atta-5kg\"", "error": "Not Found" }
| Exception | Status |
|---|---|
BadRequestException |
400 |
UnauthorizedException |
401 |
ForbiddenException |
403 |
NotFoundException |
404 |
ConflictException |
409 |
UnprocessableEntityException |
422 |
InternalServerErrorException |
500 |
Throw from the service, not the controller. The service knows the product was not found; the controller just passes the result along. That keeps the rule in one place.
Anything else you throw becomes a 500 with a generic message — the details are logged, not sent. That is correct: an unexpected error must never leak a database message to a client.
Never return a raw database row
@Get(":id")
async findOne(@Param("id") id: string) {
return this.prisma.user.findUnique({ where: { id } }); // includes passwordHash
}
Adding a column to a table should never change what your API exposes. Select explicitly, or use a serialisation interceptor:
return this.prisma.user.findUnique({
where: { id },
select: { id: true, name: true, email: true },
});
This is how password hashes end up in API responses, and it is always an accident.
Testing one
// apps/api/src/products/products.controller.spec.ts
import { Test } from "@nestjs/testing";
describe("ProductsController", () => {
let controller: ProductsController;
const service = { findBySlug: jest.fn() };
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [ProductsController],
providers: [{ provide: ProductsService, useValue: service }],
}).compile();
controller = moduleRef.get(ProductsController);
});
it("returns the product from the service", async () => {
service.findBySlug.mockResolvedValue({ id: "1", slug: "atta-5kg" });
await expect(controller.findOne("atta-5kg")).resolves.toEqual({
id: "1",
slug: "atta-5kg",
});
});
});
No HTTP server, no database — the service is replaced with a fake. This is only possible because the controller is thin; a controller containing business logic would need a real database to test.
Testability is the practical argument for thin controllers, not tidiness.
Check your work
Why return a value rather than use @Res(): NestJS serialises the return
value and applies interceptors. Taking the response object over turns that off.
Default status codes: 200 for everything except @Post, which is 201.
Why @Get("featured") must come before @Get(":slug"): routes match in
declaration order, so the dynamic one swallows the specific one.
Correct length of a controller method: one to three lines. More means logic has leaked in.
Where to throw a NotFoundException: in the service, where the fact is
known.
Why not return a raw database row: adding a column silently changes your API, and that is how password hashes get exposed. Select fields explicitly.
Why thin controllers are testable: the service can be replaced with a fake, so no HTTP server or database is needed.
Practice
- Build a
ProductsControllerwith list and get-one endpoints. Call both with curl. - Add
@Get("featured")after@Get(":slug")and watch it 404. Reorder and fix it. - Add a
@Postand confirm it returns 201 without you setting anything. - Add a
@Deletereturning 204 with@HttpCode. - Read
?page=2with@Queryand logtypeof page. Confirm it is a string. - Throw
NotFoundExceptionfrom a service for an unknown slug. Check the status and body with curl. - Throw a plain
new Error("database exploded")and confirm the client gets a generic 500 with no detail, while your terminal has the real message. - Return a full user row including a password field, see it in the response,
then fix it with
select. - Write the controller test with a faked service and run it.
Next: providers and dependency injection — how the service got into the controller.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship