RizTech Academy logo
RizTech Academy
NestJS FundamentalsLesson 3 of 630 min

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

  1. Build a ProductsController with list and get-one endpoints. Call both with curl.
  2. Add @Get("featured") after @Get(":slug") and watch it 404. Reorder and fix it.
  3. Add a @Post and confirm it returns 201 without you setting anything.
  4. Add a @Delete returning 204 with @HttpCode.
  5. Read ?page=2 with @Query and log typeof page. Confirm it is a string.
  6. Throw NotFoundException from a service for an unknown slug. Check the status and body with curl.
  7. 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.
  8. Return a full user row including a password field, see it in the response, then fix it with select.
  9. 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