Skip to main content

Controllers in NestJS: Handling HTTP Requests

Imagine you're building a restaurant. The kitchen (services) prepares the food, but you need waiters (controllers) to take orders from customers and deliver the meals. Controllers in NestJS work exactly like this - they're the interface between the outside world and your application's business logic.

Today, we'll discover how controllers handle everything that comes through HTTP - from simple GET requests to complex file uploads. By the end, you'll be building RESTful APIs with confidence.

Quick Reference​

Basic controller:

@Controller("users")
export class UsersController {
@Get()
findAll() {
return ["user1", "user2"];
}

@Get(":id")
findOne(@Param("id") id: string) {
return { id };
}

@Post()
create(@Body() data: any) {
return data;
}
}

HTTP Methods:

  • @Get() - Retrieve data
  • @Post() - Create new resource
  • @Put()/@Patch() - Update resource
  • @Delete() - Remove resource

Parameter Extraction:

  • @Param('id') - URL parameters
  • @Query('search') - Query strings
  • @Body() - Request body
  • @Headers('authorization') - HTTP headers

Gotchas:

  • ⚠️ Route order matters - specific routes before generic ones
  • ⚠️ Always use @Controller() decorator
  • ⚠️ Return values auto-serialize to JSON

What You Need to Know First​

Required reading:

Technical prerequisites:

  • HTTP basics: Understanding GET, POST, PUT, DELETE methods
  • REST API concepts: What RESTful routing means
  • TypeScript basics: Classes, decorators, and types
  • JSON: How data is formatted and exchanged

What We'll Cover in This Article​

By the end of this guide, you'll understand:

  • What controllers do and why they're essential
  • How to define routes using decorators
  • All HTTP methods (@Get, @Post, @Put, @Patch, @Delete)
  • How to extract data from requests (params, query, body, headers)
  • Route wildcards and patterns
  • Status codes and custom responses
  • Request and response objects
  • Async/await in controllers

What We'll Explain Along the Way​

These concepts will be explained with examples:

  • RESTful API design patterns
  • Route parameter extraction mechanisms
  • JSON serialization (automatic conversion)
  • HTTP status codes and when to use them
  • Express request/response objects (under the hood)
  • Async operations in route handlers

What Are Controllers?​

Controllers are the entry point for all HTTP requests in your NestJS application. They're like receptionists at a hotel - they greet visitors, understand what they need, and direct them to the right services.

Visual: How Controllers Fit in the Architecture​

Let's see where controllers sit in the NestJS architecture:

Flow explanation:

  1. Client sends HTTP request (GET /users)
  2. Controller receives request and extracts data
  3. Service handles business logic (validation, calculations)
  4. Database stores/retrieves data
  5. Response flows back through the same path

The Controller's Role​

// Think of a controller as a receptionist desk

@Controller("hotel")
export class HotelController {
constructor(private hotelService: HotelService) {}

// Guest asks: "Do you have any rooms available?"
@Get("rooms")
getAvailableRooms() {
// Controller delegates to service (front desk calls the manager)
return this.hotelService.findAvailableRooms();
}

// Guest says: "I'd like to book a room"
@Post("bookings")
createBooking(@Body() bookingData: CreateBookingDto) {
// Controller passes the request to service
return this.hotelService.createBooking(bookingData);
}

// Guest asks: "What's the status of my booking #123?"
@Get("bookings/:id")
getBooking(@Param("id") id: string) {
// Controller extracts ID and asks service
return this.hotelService.findBooking(id);
}
}

Key responsibilities:

  1. Receive HTTP requests - Handle incoming API calls
  2. Extract data - Get parameters, query strings, body data
  3. Validate input - Ensure data is correct (we'll cover this later)
  4. Delegate to services - Let business logic handle the work
  5. Return responses - Send data back to the client

What controllers DON'T do:

  • ❌ Business logic (that's for services)
  • ❌ Database queries (that's for services/repositories)
  • ❌ Complex calculations (that's for services)
  • ❌ External API calls (that's for services)

Creating Your First Controller​

Let's build a simple controller step by step:

// Step 1: Import necessary decorators
import { Controller, Get } from "@nestjs/common";

// Step 2: Define the controller with @Controller decorator
@Controller("api/products")
// This means all routes start with /api/products
export class ProductsController {
// Step 3: Define a route handler
@Get()
// This handles GET /api/products
findAll() {
return ["Product 1", "Product 2", "Product 3"];
}
}

What's happening here:

@Controller("api/products")
// URL base path: http://localhost:3000/api/products
// └─────────────┘
// This part comes from decorator
export class ProductsController {
@Get()
// Full URL: http://localhost:3000/api/products
// Method: GET
findAll() {
// Whatever you return gets sent as JSON automatically
return ["Product 1", "Product 2", "Product 3"];
}

@Get("featured")
// Full URL: http://localhost:3000/api/products/featured
// Method: GET
getFeatured() {
return ["Featured Product 1"];
}
}

How Routing Works​

NestJS combines the controller path with the method path:

Visual representation of route building:

@Controller("users") // Base path
export class UsersController {
@Get() // Route path (empty)
// Full route: GET /users
findAll() {
return "All users";
}

@Get("active") // Route path: 'active'
// Full route: GET /users/active
findActive() {
return "Active users";
}

@Get("profile/:id") // Route path: 'profile/:id'
// Full route: GET /users/profile/123
getProfile(@Param("id") id: string) {
return `Profile for user ${id}`;
}
}

Route combination diagram:

Controller Path + Method Path = Full Route

/users + (nothing) = /users
/users + /active = /users/active
/users + /profile/:id = /users/profile/:id

HTTP Methods: The Complete Guide​

HTTP methods define what action you want to perform. Think of them as verbs in a sentence: GET (retrieve), POST (create), PUT (replace), PATCH (update), DELETE (remove).

Visual: HTTP Methods Overview​

@Get() - Retrieving Data​

Use GET when you want to read data without changing anything:

@Controller("products")
export class ProductsController {
constructor(private productsService: ProductsService) {}

// Get all products
@Get()
// URL: GET /products
findAll() {
return this.productsService.findAll();
// Returns: [{ id: 1, name: 'Laptop' }, { id: 2, name: 'Phone' }]
}

// Get one product by ID
@Get(":id")
// URL: GET /products/5
findOne(@Param("id") id: string) {
return this.productsService.findOne(id);
// Returns: { id: 5, name: 'Tablet', price: 299 }
}

// Get featured products
@Get("featured/top")
// URL: GET /products/featured/top
getFeatured() {
return this.productsService.findFeatured();
// Returns: [{ id: 1, name: 'Best Seller', featured: true }]
}

// Get products by category
@Get("category/:category")
// URL: GET /products/category/electronics
getByCategory(@Param("category") category: string) {
return this.productsService.findByCategory(category);
}
}

GET request characteristics:

  • ✅ Should be idempotent (calling it multiple times has the same effect)
  • ✅ Should be safe (doesn't modify data)
  • ✅ Can be cached by browsers
  • ✅ Can be bookmarked
  • ✅ Data in URL (query parameters)

Real-world examples:

GET /users → List all users
GET /users/123 → Get user with ID 123
GET /users/123/posts → Get posts by user 123
GET /products?search=laptop → Search products
GET /posts?page=2&limit=10 → Paginated posts

@Post() - Creating New Resources​

Use POST when you want to create something new:

@Controller("products")
export class ProductsController {
constructor(private productsService: ProductsService) {}

// Create a new product
@Post()
// URL: POST /products
// Body: { name: "New Product", price: 99 }
create(@Body() createProductDto: CreateProductDto) {
return this.productsService.create(createProductDto);
// Returns: { id: 10, name: "New Product", price: 99, createdAt: "2024-01-01" }
}

// Upload product image
@Post(":id/images")
// URL: POST /products/5/images
uploadImage(@Param("id") id: string, @Body() imageData: any) {
return this.productsService.uploadImage(id, imageData);
}

// Bulk create products
@Post("bulk")
// URL: POST /products/bulk
// Body: [{ name: "Product 1" }, { name: "Product 2" }]
createMany(@Body() products: CreateProductDto[]) {
return this.productsService.createMany(products);
}
}

POST request characteristics:

  • ✅ Creates new resources
  • ✅ NOT idempotent (calling twice creates two items)
  • ✅ Data in request body (not URL)
  • ✅ Can send large amounts of data
  • ✅ Returns created resource with ID

When to use POST:

POST /users → Create new user
POST /products → Create new product
POST /posts/123/comments → Add comment to post
POST /auth/login → Submit login credentials
POST /orders → Place new order

@Put() - Replacing Resources​

Use PUT when you want to completely replace a resource:

@Controller("products")
export class ProductsController {
constructor(private productsService: ProductsService) {}

// Replace entire product
@Put(":id")
// URL: PUT /products/5
// Body: { name: "Updated Product", price: 199, description: "New desc", stock: 50 }
update(@Param("id") id: string, @Body() updateProductDto: UpdateProductDto) {
return this.productsService.replace(id, updateProductDto);
// All fields must be provided - replaces entire resource
}
}

PUT request characteristics:

  • ✅ Replaces the entire resource
  • ✅ IS idempotent (same result every time)
  • ✅ Requires all fields in body
  • ✅ Creates resource if doesn't exist (sometimes)

PUT vs POST:

// POST - Creating new (ID assigned by server)
POST /products
Body: { name: "Laptop", price: 999 }
Response: { id: 123, name: "Laptop", price: 999 }

// PUT - Replacing existing (ID in URL)
PUT /products/123
Body: { name: "Gaming Laptop", price: 1299, description: "High-end", stock: 10 }
Response: { id: 123, name: "Gaming Laptop", price: 1299, ... }
// Notice: ALL fields must be provided

@Patch() - Partial Updates​

Use PATCH when you want to partially update specific fields:

@Controller("products")
export class ProductsController {
constructor(private productsService: ProductsService) {}

// Update specific fields
@Patch(":id")
// URL: PATCH /products/5
// Body: { price: 149 } ← Only updating price
partialUpdate(
@Param("id") id: string,
@Body() updateData: Partial<UpdateProductDto>
) {
return this.productsService.update(id, updateData);
// Returns: { id: 5, name: "Laptop", price: 149, ... }
// Only price changed, other fields remain the same
}

// Update product status only
@Patch(":id/status")
// URL: PATCH /products/5/status
// Body: { status: "published" }
updateStatus(@Param("id") id: string, @Body("status") status: string) {
return this.productsService.updateStatus(id, status);
}

// Increment view count
@Patch(":id/views")
// URL: PATCH /products/5/views
incrementViews(@Param("id") id: string) {
return this.productsService.incrementViews(id);
// No body needed - just increment the counter
}
}

PATCH request characteristics:

  • ✅ Updates only specified fields
  • ✅ Other fields remain unchanged
  • ✅ More flexible than PUT
  • ✅ Commonly used in modern APIs

PATCH vs PUT:

// Existing product:
{
id: 123,
name: "Laptop",
price: 999,
description: "Gaming laptop",
stock: 50
}

// PUT - Must provide ALL fields
PUT /products/123
Body: {
name: "Laptop",
price: 1099, ← Only want to change this
description: "Gaming laptop", ← Must include
stock: 50 ← Must include
}

// PATCH - Only provide what changes
PATCH /products/123
Body: {
price: 1099 ← Only this field
}
// Result: Only price updated, everything else stays the same

@Delete() - Removing Resources​

Use DELETE when you want to remove a resource:

@Controller("products")
export class ProductsController {
constructor(private productsService: ProductsService) {}

// Delete a product
@Delete(":id")
// URL: DELETE /products/5
remove(@Param("id") id: string) {
return this.productsService.remove(id);
// Returns: { message: "Product deleted successfully" }
// Or: { deleted: true, id: 5 }
}

// Soft delete (mark as inactive)
@Delete(":id/soft")
// URL: DELETE /products/5/soft
softDelete(@Param("id") id: string) {
return this.productsService.softDelete(id);
// Product still exists but marked as deleted
}

// Delete all products (dangerous!)
@Delete()
// URL: DELETE /products
removeAll() {
return this.productsService.removeAll();
// Usually requires authentication and confirmation
}

// Delete multiple products
@Delete("batch")
// URL: DELETE /products/batch
// Body: { ids: [1, 2, 3, 4, 5] }
removeBatch(@Body("ids") ids: number[]) {
return this.productsService.removeMany(ids);
}
}

DELETE request characteristics:

  • ✅ Removes resources
  • ✅ IS idempotent (deleting twice has same effect as once)
  • ✅ Typically returns success message or deleted resource
  • ✅ May return 204 No Content (empty response)

Real-world examples:

DELETE /users/123 → Delete user
DELETE /posts/456 → Delete post
DELETE /cart/items/789 → Remove item from cart
DELETE /sessions/current → Logout (delete session)
DELETE /files/photo.jpg → Delete uploaded file

HTTP Methods Summary​

MethodPurposeIdempotent?Safe?Body?Example
GETRetrieve data✅ Yes✅ Yes❌ NoGet user list
POSTCreate resource❌ No❌ No✅ YesCreate user
PUTReplace resource✅ Yes❌ No✅ YesReplace user
PATCHUpdate fields✅ Yes*❌ No✅ YesUpdate email
DELETERemove resource✅ Yes❌ No❌ NoDelete user

*PATCH is typically idempotent but depends on implementation

Extracting Data from Requests​

Controllers need to access data from various parts of the HTTP request. Let's explore every way to extract data:

Visual: Request Data Flow​

Complete HTTP Request Structure:

GET /users/123/posts?page=2&limit=10 HTTP/1.1
│ │ │ │ └─────────────┘
│ │ │ │ └─ Query Parameters (@Query)
│ │ │ └─ URL Parameters (@Param)
│ │ └─ Controller base path
│ └─ HTTP Method (@Get, @Post, etc.)
│
├─ Headers:
│ ├─ Authorization: Bearer token123 (@Headers)
│ ├─ Content-Type: application/json
│ └─ User-Agent: Mozilla/5.0...
│
└─ Body (for POST/PUT/PATCH): (@Body)
{
"title": "New Post",
"content": "Post content here"
}

@Param() - URL Parameters​

URL parameters are part of the route path itself:

@Controller('users')
export class UsersController {
// Single parameter
@Get(':id')
// URL: GET /users/123
findOne(@Param('id') id: string) {
console.log(id); // "123"
return this.usersService.findOne(id);
}

// Multiple parameters
@Get(':userId/posts/:postId')
// URL: GET /users/123/posts/456
getPost(
@Param('userId') userId: string,
@Param('postId') postId: string
) {
console.log(userId); // "123"
console.log(postId); // "456"
return this.postsService.findOne(userId, postId);
}

// Get all params as object
@Get(':userId/posts/:postId/comments/:commentId')
// URL: GET /users/123/posts/456/comments/789
getComment(@Param() params: any) {
console.log(params);
// { userId: "123", postId: "456", commentId: "789" }
return this.commentsService.findOne(params);
}

// Type-safe params with interface
interface PostParams {
userId: string;
postId: string;
}

@Get(':userId/posts/:postId')
getPostTypeSafe(@Param() params: PostParams) {
// TypeScript knows params.userId and params.postId exist
return this.postsService.findOne(params.userId, params.postId);
}
}

URL parameter patterns:

/users/:id → /users/123
/users/:id/posts/:postId → /users/123/posts/456
/categories/:category/items/:id → /categories/electronics/items/789
/:version/api/users/:id → /v1/api/users/123

Key points:

  • Parameters are always strings (convert to numbers if needed)
  • Parameter names match the route definition
  • Use meaningful parameter names (userId not just id)

@Query() - Query String Parameters​

Query parameters come after ? in the URL:

@Controller('products')
export class ProductsController {
// Single query parameter
@Get()
// URL: GET /products?search=laptop
findAll(@Query('search') search: string) {
console.log(search); // "laptop"
return this.productsService.search(search);
}

// Multiple query parameters
@Get()
// URL: GET /products?category=electronics&minPrice=100&maxPrice=1000
filter(
@Query('category') category: string,
@Query('minPrice') minPrice: string,
@Query('maxPrice') maxPrice: string
) {
// Convert strings to numbers
return this.productsService.filter(
category,
parseInt(minPrice),
parseInt(maxPrice)
);
}

// Get all query params as object
@Get()
// URL: GET /products?page=2&limit=20&sort=price&order=asc
findAllWithPagination(@Query() query: any) {
console.log(query);
// {
// page: "2",
// limit: "20",
// sort: "price",
// order: "asc"
// }
return this.productsService.paginate(query);
}

// Type-safe query params
interface QueryParams {
page?: string;
limit?: string;
search?: string;
category?: string;
}

@Get()
findWithTypeSafety(@Query() query: QueryParams) {
const page = parseInt(query.page) || 1;
const limit = parseInt(query.limit) || 10;

return this.productsService.paginate({
page,
limit,
search: query.search,
category: query.category
});
}
}

Query parameter examples:

?search=laptop → Single parameter
?page=2&limit=10 → Pagination
?sort=price&order=desc → Sorting
?filter=active&category=tech → Filtering
?fields=name,price,stock → Field selection

Params vs Query:

// URL Parameters (part of route)
GET /users/123/posts/456
@Get(':userId/posts/:postId')
getPost(@Param('userId') userId, @Param('postId') postId)

// Query Parameters (optional filters)
GET /posts?author=123&status=published&page=2
@Get()
getPosts(@Query('author') author, @Query('status') status, @Query('page') page)

@Body() - Request Body​

The request body contains data sent with POST, PUT, PATCH requests:

@Controller("users")
export class UsersController {
// Extract entire body
@Post()
// Body: { name: "John", email: "john@example.com", age: 30 }
create(@Body() createUserDto: CreateUserDto) {
console.log(createUserDto);
// { name: "John", email: "john@example.com", age: 30 }
return this.usersService.create(createUserDto);
}

// Extract specific field from body
@Post()
// Body: { name: "John", email: "john@example.com", password: "secret123" }
createWithFields(
@Body("name") name: string,
@Body("email") email: string,
@Body("password") password: string
) {
console.log(name); // "John"
console.log(email); // "john@example.com"
console.log(password); // "secret123"

return this.usersService.create({ name, email, password });
}

// Complex nested body
@Post("profile")
// Body: {
// name: "John",
// email: "john@example.com",
// address: {
// street: "123 Main St",
// city: "New York",
// country: "USA"
// },
// preferences: {
// newsletter: true,
// notifications: false
// }
// }
createProfile(@Body() profileData: CreateProfileDto) {
console.log(profileData.address.city); // "New York"
console.log(profileData.preferences.newsletter); // true

return this.usersService.createProfile(profileData);
}

// Array in body
@Post("bulk")
// Body: [
// { name: "User1", email: "user1@example.com" },
// { name: "User2", email: "user2@example.com" }
// ]
createMany(@Body() users: CreateUserDto[]) {
console.log(users.length); // 2
return this.usersService.createMany(users);
}

// Combining body with params
@Patch(":id")
// URL: PATCH /users/123
// Body: { name: "Updated Name" }
update(@Param("id") id: string, @Body() updateData: UpdateUserDto) {
return this.usersService.update(id, updateData);
}
}

Body data examples:

// Simple object
{ "name": "John", "email": "john@example.com" }

// Nested object
{
"user": {
"name": "John",
"profile": {
"age": 30,
"location": "NYC"
}
}
}

// Array
[
{ "id": 1, "name": "Item 1" },
{ "id": 2, "name": "Item 2" }
]

// Mixed
{
"action": "update",
"users": [123, 456, 789],
"data": { "status": "active" }
}

@Headers() - HTTP Headers​

Headers contain metadata about the request:

@Controller("api")
export class ApiController {
// Extract specific header
@Get("protected")
getProtectedData(@Headers("authorization") authorization: string) {
console.log(authorization); // "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

// Verify token
const token = authorization.replace("Bearer ", "");
return this.authService.validateToken(token);
}

// Get all headers
@Get("debug")
debug(@Headers() headers: any) {
console.log(headers);
// {
// 'content-type': 'application/json',
// 'user-agent': 'Mozilla/5.0...',
// 'authorization': 'Bearer token...',
// 'accept': '*/*',
// 'host': 'localhost:3000'
// }
return { headers };
}

// Multiple headers
@Post("upload")
uploadFile(
@Headers("content-type") contentType: string,
@Headers("content-length") contentLength: string,
@Headers("authorization") auth: string,
@Body() data: any
) {
console.log(contentType); // "multipart/form-data"
console.log(contentLength); // "1024"
console.log(auth); // "Bearer token..."

return this.fileService.upload(data, contentType);
}

// Custom headers
@Get("api-key-auth")
apiKeyAuth(@Headers("x-api-key") apiKey: string) {
if (!this.validateApiKey(apiKey)) {
throw new UnauthorizedException("Invalid API key");
}
return { success: true };
}
}

Common headers:

Authorization: Bearer eyJhbGci... → Authentication token
Content-Type: application/json → Body format
Accept: application/json → Desired response format
User-Agent: Mozilla/5.0... → Client information
X-API-Key: abc123... → Custom API key
X-Request-ID: uuid-here → Request tracking

@Req() and @Res() - Full Request/Response Objects​

Sometimes you need access to the raw Express request/response:

import { Controller, Get, Req, Res } from "@nestjs/common";
import { Request, Response } from "express";

@Controller("advanced")
export class AdvancedController {
// Access full request object
@Get("request-details")
getRequestDetails(@Req() request: Request) {
return {
method: request.method, // GET
url: request.url, // /advanced/request-details
headers: request.headers, // All headers
query: request.query, // Query parameters
params: request.params, // URL parameters
body: request.body, // Request body
ip: request.ip, // Client IP
hostname: request.hostname, // Host
protocol: request.protocol, // http or https
originalUrl: request.originalUrl, // Full original URL
};
}

// Manually control response
@Get("custom-response")
customResponse(@Res() response: Response) {
// Warning: When you use @Res(), you must send the response manually
response
.status(200)
.header("X-Custom-Header", "MyValue")
.json({ message: "Custom response" });
}

// Set cookies
@Get("set-cookie")
setCookie(@Res() response: Response) {
response.cookie("session_id", "abc123", {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 1 day
});

response.json({ message: "Cookie set" });
}

// Download file
@Get("download")
downloadFile(@Res() response: Response) {
const file = "/path/to/file.pdf";
response.download(file);
}

// Stream response
@Get("stream")
streamData(@Res() response: Response) {
response.setHeader("Content-Type", "text/event-stream");
response.setHeader("Cache-Control", "no-cache");

const interval = setInterval(() => {
response.write(`data: ${Date.now()}\n\n`);
}, 1000);

// Clean up on client disconnect
response.on("close", () => {
clearInterval(interval);
});
}
}

⚠️ Important about @Res():

// ❌ Wrong: Using @Res() prevents automatic JSON serialization
@Get()
getData(@Res() response: Response) {
return { data: 'This wont work!' };
// Response never sent!
}

// ✅ Right: Manually send response when using @Res()
@Get()
getData(@Res() response: Response) {
response.json({ data: 'This works!' });
}

// ✅ Best: Don't use @Res() unless you need manual control
@Get()
getData() {
return { data: 'This automatically becomes JSON!' };
// NestJS handles response serialization
}

Route Patterns and Wildcards​

NestJS supports powerful route matching patterns:

Exact Matches​

@Controller("products")
export class ProductsController {
@Get("featured")
// Matches: GET /products/featured
// Does NOT match: GET /products/featured/top
getFeatured() {
return "Featured products";
}

@Get("on-sale")
// Matches: GET /products/on-sale
getOnSale() {
return "Products on sale";
}
}

Wildcard Routes​

Use wildcards to match multiple patterns:

@Controller("files")
export class FilesController {
// Asterisk matches any characters
@Get("*.pdf")
// Matches:
// - /files/document.pdf
// - /files/report.pdf
// - /files/my-file.pdf
// Does NOT match:
// - /files/image.jpg
// - /files/document.pdf.backup
getPdfFiles(@Req() request: Request) {
console.log(request.url); // The full matched URL
return "PDF file handler";
}

// Question mark matches exactly one character
@Get("file-?.txt")
// Matches:
// - /files/file-1.txt
// - /files/file-a.txt
// - /files/file-x.txt
// Does NOT match:
// - /files/file-10.txt (two characters)
// - /files/file.txt (no character)
getFileWithOneChar() {
return "Single character file";
}

// Parentheses for grouping (character sets)
@Get("file-[abc].txt")
// Matches:
// - /files/file-a.txt
// - /files/file-b.txt
// - /files/file-c.txt
// Does NOT match:
// - /files/file-d.txt
getFileWithSpecificChar() {
return "Specific character file";
}
}

Catch-All Routes​

Sometimes you want a fallback route:

@Controller()
export class AppController {
// Regular routes first
@Get("about")
about() {
return "About page";
}

@Get("contact")
contact() {
return "Contact page";
}

// Catch-all route (must be LAST)
@Get("*")
// Matches any route not matched above
notFound() {
return "Page not found - 404";
}
}

Route Order Matters!​

Critical concept: Routes are matched in the order they're defined:

❌ Wrong order - 'me' will never be reached:

@Controller("users")
export class UsersController {
@Get(":id")
// This matches ANY string including "me"
findOne(@Param("id") id: string) {
return `User ${id}`;
}

@Get("me")
// This will NEVER execute because :id matches first
getCurrentUser() {
return "Current user";
}
}

// When you visit /users/me:
// 1. Router checks @Get(':id') - MATCHES! (id = "me")
// 2. Returns `User me` instead of calling getCurrentUser()

✅ Correct order with proper flow:

Correct implementation:

@Controller("users")
export class UsersController {
// Specific routes FIRST
@Get("me")
// Matches: GET /users/me
getCurrentUser() {
return "Current user profile";
}

@Get("active")
// Matches: GET /users/active
getActiveUsers() {
return "Active users";
}

@Get("admin")
// Matches: GET /users/admin
getAdmins() {
return "Admin users";
}

// Dynamic routes LAST
@Get(":id")
// Matches: GET /users/123, /users/abc, etc.
// But NOT /users/me, /users/active, /users/admin (already matched above)
findOne(@Param("id") id: string) {
return `User ${id}`;
}
}

Route matching priority diagram:

Rule of thumb:

  1. Static routes first (/users/me, /users/active)
  2. Dynamic routes last (/users/:id)
  3. Catch-all routes absolutely last (*)

Complex Route Patterns​

@Controller("api")
export class ApiController {
// Multiple path segments
@Get("v1/users/:userId/posts/:postId/comments")
// Matches: /api/v1/users/123/posts/456/comments
getComments(
@Param("userId") userId: string,
@Param("postId") postId: string
) {
return this.commentsService.find(userId, postId);
}

// Optional segments using wildcards
@Get("search/*")
// Matches:
// - /api/search/users
// - /api/search/products
// - /api/search/anything
search(@Req() req: Request) {
const searchPath = req.path.replace("/api/search/", "");
return this.searchService.search(searchPath);
}

// Regex-like patterns
@Get("files/:filename([a-z0-9-]+).pdf")
// Matches: /api/files/my-document-123.pdf
// Does NOT match: /api/files/My_Document.pdf (uppercase, underscore)
getPdfFile(@Param("filename") filename: string) {
return this.fileService.getPdf(filename);
}
}

Status Codes and Custom Responses​

HTTP status codes tell the client what happened with their request.

Visual: HTTP Status Code Categories​

Request/Response Flow with Status Codes​

Default Status Codes​

NestJS automatically sets appropriate status codes:

@Controller("products")
export class ProductsController {
@Get()
// Default: 200 OK
findAll() {
return ["Product 1", "Product 2"];
}

@Post()
// Default: 201 Created (for POST requests)
create(@Body() data: any) {
return { id: 1, ...data };
}

@Get(":id")
// Default: 200 OK
// But if not found, throw exception (see below)
findOne(@Param("id") id: string) {
const product = this.productsService.findOne(id);
if (!product) {
throw new NotFoundException("Product not found");
// Automatically returns 404
}
return product;
}
}

Custom Status Codes​

Use @HttpCode() decorator to set custom status codes:

import {
Controller,
Post,
Delete,
HttpCode,
Body,
Param,
} from "@nestjs/common";

@Controller("users")
export class UsersController {
@Post()
@HttpCode(201) // Created
create(@Body() userData: any) {
return this.usersService.create(userData);
}

@Delete(":id")
@HttpCode(204) // No Content (successful deletion with no response body)
remove(@Param("id") id: string) {
this.usersService.remove(id);
// No return statement - 204 means "success but no content"
}

@Post("logout")
@HttpCode(200) // OK (instead of default 201 for POST)
logout() {
return { message: "Logged out successfully" };
}

@Post("verify-email")
@HttpCode(202) // Accepted (request accepted but not yet processed)
verifyEmail(@Body("token") token: string) {
// Email verification happens asynchronously
this.emailService.queueVerification(token);
return { message: "Verification email sent" };
}
}

Common HTTP Status Codes​

Success codes (2xx):

200 OK - Standard success response
201 Created - Resource created (default for POST)
202 Accepted - Request accepted, processing async
204 No Content - Success, but no response body

Client error codes (4xx):

400 Bad Request - Invalid request data
401 Unauthorized - Authentication required
403 Forbidden - Authenticated but not allowed
404 Not Found - Resource doesn't exist
409 Conflict - Resource conflict (duplicate email, etc.)
422 Unprocessable - Validation failed
429 Too Many Requests - Rate limit exceeded

Server error codes (5xx):

500 Internal Server Error - Something went wrong on server
502 Bad Gateway - Upstream service error
503 Service Unavailable - Server temporarily down

Exception Classes (Built-in)​

NestJS provides exception classes that automatically set the correct status code:

import {
BadRequestException,
UnauthorizedException,
NotFoundException,
ForbiddenException,
ConflictException,
InternalServerErrorException,
} from "@nestjs/common";

@Controller("users")
export class UsersController {
@Get(":id")
findOne(@Param("id") id: string) {
const user = this.usersService.findOne(id);

if (!user) {
// Automatically returns 404
throw new NotFoundException("User not found");
}

return user;
}

@Post()
create(@Body() userData: CreateUserDto) {
const existingUser = this.usersService.findByEmail(userData.email);

if (existingUser) {
// Automatically returns 409
throw new ConflictException("Email already exists");
}

return this.usersService.create(userData);
}

@Post("login")
login(@Body() credentials: LoginDto) {
const user = this.authService.validateCredentials(credentials);

if (!user) {
// Automatically returns 401
throw new UnauthorizedException("Invalid credentials");
}

return this.authService.generateToken(user);
}

@Delete(":id")
remove(@Param("id") id: string, @Headers("authorization") auth: string) {
if (!this.authService.isAdmin(auth)) {
// Automatically returns 403
throw new ForbiddenException("Only admins can delete users");
}

return this.usersService.remove(id);
}

@Post()
createWithValidation(@Body() userData: CreateUserDto) {
if (!userData.email || !userData.password) {
// Automatically returns 400
throw new BadRequestException("Email and password are required");
}

return this.usersService.create(userData);
}
}

Custom Error Messages​

@Controller("products")
export class ProductsController {
@Get(":id")
findOne(@Param("id") id: string) {
const product = this.productsService.findOne(id);

if (!product) {
// Simple message
throw new NotFoundException("Product not found");

// Or detailed message object
throw new NotFoundException({
statusCode: 404,
message: "Product not found",
error: "Not Found",
details: {
productId: id,
timestamp: new Date().toISOString(),
},
});
}

return product;
}

@Post()
create(@Body() data: CreateProductDto) {
try {
return this.productsService.create(data);
} catch (error) {
// Custom error handling
if (error.code === "DUPLICATE_KEY") {
throw new ConflictException("Product with this SKU already exists");
}

// Generic error
throw new InternalServerErrorException("Failed to create product");
}
}
}

Async Operations in Controllers​

Most real-world operations are asynchronous (database queries, API calls, etc.):

Visual: Async Request Processing​

Using async/await​

@Controller("users")
export class UsersController {
constructor(private usersService: UsersService) {}

// Async controller method
@Get()
async findAll(): Promise<User[]> {
// Wait for database query to complete
const users = await this.usersService.findAll();
return users;
// NestJS automatically waits for the promise to resolve
}

@Get(":id")
async findOne(@Param("id") id: string): Promise<User> {
const user = await this.usersService.findOne(id);

if (!user) {
throw new NotFoundException("User not found");
}

return user;
}

@Post()
async create(@Body() userData: CreateUserDto): Promise<User> {
// Multiple async operations
const existingUser = await this.usersService.findByEmail(userData.email);

if (existingUser) {
throw new ConflictException("Email already exists");
}

// Create user
const user = await this.usersService.create(userData);

// Send welcome email (don't wait for it)
this.emailService.sendWelcome(user.email).catch((err) => {
console.error("Failed to send welcome email:", err);
});

return user;
}

@Post("bulk")
async createMany(@Body() users: CreateUserDto[]): Promise<User[]> {
// Process multiple items in parallel
const createdUsers = await Promise.all(
users.map((userData) => this.usersService.create(userData))
);

return createdUsers;
}
}

Error Handling in Async Controllers​

@Controller("api")
export class ApiController {
@Get("external-data")
async fetchExternalData(): Promise<any> {
try {
// Call external API
const response = await this.httpService.get(
"https://api.example.com/data"
);
return response.data;
} catch (error) {
// Handle different error scenarios
if (error.response?.status === 404) {
throw new NotFoundException("External resource not found");
}

if (error.code === "ETIMEDOUT") {
throw new RequestTimeoutException("External API timeout");
}

// Generic error
throw new InternalServerErrorException("Failed to fetch external data");
}
}

@Post("process")
async processData(@Body() data: any): Promise<any> {
// Long-running operation with timeout
const result = await Promise.race([
this.dataService.process(data),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 30000)
),
]);

return result;
}

@Get("parallel-operations")
async parallelOps(): Promise<any> {
// Run multiple operations in parallel
const [users, products, orders] = await Promise.all([
this.usersService.findAll(),
this.productsService.findAll(),
this.ordersService.findAll(),
]);

return { users, products, orders };
}
}

Visual: Parallel vs Sequential Operations​

Code comparison:

// ❌ Sequential - Takes 450ms
@Get('slow')
async getDataSequential() {
const users = await this.usersService.findAll(); // 100ms
const products = await this.productsService.findAll(); // 150ms
const orders = await this.ordersService.findAll(); // 200ms
return { users, products, orders }; // Total: 450ms
}

// ✅ Parallel - Takes 200ms (slowest operation)
@Get('fast')
async getDataParallel() {
const [users, products, orders] = await Promise.all([
this.usersService.findAll(), // 100ms ┐
this.productsService.findAll(), // 150ms ├─ All run at once
this.ordersService.findAll() // 200ms ┘
]);
return { users, products, orders }; // Total: 200ms
}

Returning Promises Directly​

@Controller("users")
export class UsersController {
// You can return promises directly (NestJS will wait)
@Get()
findAll(): Promise<User[]> {
// No async/await needed if you're just returning the promise
return this.usersService.findAll();
}

@Get(":id")
findOne(@Param("id") id: string): Promise<User> {
return this.usersService.findOne(id);
}

// But async/await is better when you need to process the result
@Get(":id")
async findOneWithCheck(@Param("id") id: string): Promise<User> {
const user = await this.usersService.findOne(id);

// Can check the result before returning
if (!user) {
throw new NotFoundException();
}

return user;
}
}

Response Manipulation​

Setting Custom Headers​

import { Controller, Get, Header, Res } from "@nestjs/common";
import { Response } from "express";

@Controller("api")
export class ApiController {
// Using @Header decorator
@Get("csv-export")
@Header("Content-Type", "text/csv")
@Header("Content-Disposition", 'attachment; filename="export.csv"')
exportCsv() {
return "id,name,email\n1,John,john@example.com\n2,Jane,jane@example.com";
}

// Using response object
@Get("download")
download(@Res() response: Response) {
response.setHeader("Content-Type", "application/pdf");
response.setHeader(
"Content-Disposition",
'attachment; filename="document.pdf"'
);
response.send(pdfBuffer);
}

// Custom headers
@Get("rate-limit-info")
@Header("X-RateLimit-Limit", "100")
@Header("X-RateLimit-Remaining", "99")
@Header("X-RateLimit-Reset", "1640000000")
getRateLimitInfo() {
return { limit: 100, remaining: 99 };
}
}

Redirects​

import { Controller, Get, Redirect } from "@nestjs/common";

@Controller()
export class AppController {
// Simple redirect
@Get("old-url")
@Redirect("https://example.com/new-url", 301)
redirectOld() {
// This method won't execute - redirect happens immediately
}

// Conditional redirect
@Get("docs")
@Redirect("https://docs.example.com", 302)
getDocs(@Query("version") version: string) {
if (version && version === "v2") {
// Override the default redirect
return { url: "https://docs.example.com/v2" };
}
// Use default redirect to https://docs.example.com
}

// Dynamic redirect
@Get("short/:code")
@Redirect()
async shortUrl(@Param("code") code: string) {
const url = await this.urlService.findByCode(code);

if (!url) {
throw new NotFoundException("Short URL not found");
}

return { url: url.longUrl, statusCode: 302 };
}
}

Streaming Responses​

import { Controller, Get, Res, StreamableFile } from "@nestjs/common";
import { Response } from "express";
import { createReadStream } from "fs";

@Controller("files")
export class FilesController {
// Using StreamableFile (recommended)
@Get("download/:filename")
getFile(@Param("filename") filename: string): StreamableFile {
const file = createReadStream(`./uploads/${filename}`);
return new StreamableFile(file);
}

// Manual streaming
@Get("stream/:filename")
streamFile(@Param("filename") filename: string, @Res() response: Response) {
const file = createReadStream(`./uploads/${filename}`);

file.pipe(response);
}

// Server-Sent Events (SSE)
@Get("events")
@Header("Content-Type", "text/event-stream")
@Header("Cache-Control", "no-cache")
@Header("Connection", "keep-alive")
events(@Res() response: Response) {
// Send events every second
const interval = setInterval(() => {
const event = {
data: { timestamp: Date.now(), message: "Hello!" },
};
response.write(`data: ${JSON.stringify(event)}\n\n`);
}, 1000);

// Cleanup on disconnect
response.on("close", () => {
clearInterval(interval);
response.end();
});
}
}

Common Misconceptions​

❌ Misconception: "Controllers should contain all my logic"​

Reality: Controllers should be thin - they route requests to services that contain business logic.

Why this matters: Mixing concerns makes code hard to test, reuse, and maintain.

Example:

// ❌ Bad: Logic in controller
@Controller("users")
export class UsersController {
@Post()
async create(@Body() userData: CreateUserDto) {
// Validation logic
if (!userData.email || !userData.email.includes("@")) {
throw new BadRequestException("Invalid email");
}

// Business logic
const existingUser = await database.query(
"SELECT * FROM users WHERE email = ?",
[userData.email]
);

if (existingUser) {
throw new ConflictException("Email exists");
}

// Password hashing
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(userData.password, salt);

// Database operation
const user = await database.query(
"INSERT INTO users (email, password) VALUES (?, ?)",
[userData.email, hashedPassword]
);

// Email sending
await sendEmail(userData.email, "Welcome!");

return user;
}
}

// ✅ Good: Thin controller, logic in service
@Controller("users")
export class UsersController {
constructor(private usersService: UsersService) {}

@Post()
create(@Body() userData: CreateUserDto) {
// Controller just routes - service handles everything
return this.usersService.create(userData);
}
}

@Injectable()
export class UsersService {
async create(userData: CreateUserDto): Promise<User> {
// All logic here
await this.validateEmail(userData.email);
await this.checkDuplicate(userData.email);
const hashedPassword = await this.hashPassword(userData.password);
const user = await this.saveToDatabase(userData, hashedPassword);
await this.sendWelcomeEmail(user.email);
return user;
}
}

❌ Misconception: "Param values are automatically converted to numbers"​

Reality: All URL parameters and query strings are strings - you must convert them manually.

Why this matters: Using strings where numbers are expected causes bugs.

Example:

// ❌ Wrong: Treating param as number
@Get(':id')
findOne(@Param('id') id: string) {
// id is "123" (string), not 123 (number)
const user = this.usersService.findOne(id);

// If service expects number, this might fail
// "123" !== 123
}

// ✅ Right: Convert to number
@Get(':id')
findOne(@Param('id') id: string) {
const numericId = parseInt(id, 10);

if (isNaN(numericId)) {
throw new BadRequestException('ID must be a number');
}

return this.usersService.findOne(numericId);
}

// ✅ Better: Use ParseIntPipe (covered in future article)
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// id is automatically converted to number
// Throws 400 if conversion fails
return this.usersService.findOne(id);
}

❌ Misconception: "Using @Res() is the best practice"​

Reality: Avoid @Res() unless you need manual control - it prevents NestJS features from working.

Why this matters: Using @Res() disables automatic response handling, serialization, and interceptors.

Example:

// ❌ Bad: Unnecessary use of @Res()
@Get()
findAll(@Res() response: Response) {
const users = this.usersService.findAll();
response.json(users); // Manual response
}

// ✅ Good: Let NestJS handle it
@Get()
findAll() {
return this.usersService.findAll();
// Automatically serialized to JSON
// Interceptors and transforms work
// Much simpler!
}

// ✅ Use @Res() only when necessary
@Get('download')
download(@Res() response: Response) {
// Need manual control for file download
response.download('/path/to/file.pdf');
}

❌ Misconception: "Route order doesn't matter"​

Reality: Routes are matched from top to bottom - order is critical.

Why this matters: A catch-all route defined too early will prevent specific routes from ever matching.

Example:

// ❌ Wrong order
@Controller("users")
export class UsersController {
@Get(":id") // Matches EVERYTHING
findOne(@Param("id") id: string) {
return this.usersService.findOne(id);
}

@Get("me") // NEVER REACHED!
getCurrentUser() {
return "Current user";
}
}

// When you visit /users/me:
// - First route matches (id = "me")
// - Second route never checked

// ✅ Correct order
@Controller("users")
export class UsersController {
@Get("me") // Specific routes first
getCurrentUser() {
return "Current user";
}

@Get(":id") // Dynamic routes last
findOne(@Param("id") id: string) {
return this.usersService.findOne(id);
}
}

Troubleshooting Common Issues​

Problem: "Cannot GET /api/users"​

Symptoms: 404 error when accessing your endpoint

Common Causes:

  1. Module not imported (70%)
  2. Wrong route path (20%)
  3. Server not running (10%)

Solution:

// Step 1: Verify controller is registered in module
@Module({
controllers: [UsersController], // ✅ Must be here
providers: [UsersService],
})
export class UsersModule {}

// Step 2: Verify module is imported in AppModule
@Module({
imports: [UsersModule], // ✅ Must be here
})
export class AppModule {}

// Step 3: Check route paths
@Controller("users") // Base: /users
export class UsersController {
@Get() // Full path: /users (not /api/users unless specified)
findAll() {
return [];
}
}

// Step 4: Check server is running
// npm run start:dev should be running

Problem: "Cannot read property 'findOne' of undefined"​

Symptoms: Service method is undefined

Common Causes:

  1. Service not injected (80%)
  2. Service not in providers array (15%)
  3. Circular dependency (5%)

Solution:

// Check service is properly injected
@Controller("users")
export class UsersController {
constructor(
private usersService: UsersService // ✅ Injected here // Note: "private" is required for property creation
) {}

@Get()
findAll() {
// this.usersService is now available
return this.usersService.findAll();
}
}

// Check service is in module providers
@Module({
controllers: [UsersController],
providers: [UsersService], // ✅ Service must be here
})
export class UsersModule {}

Problem: "JSON response wraps my data in extra object"​

Symptoms: Expected [] but got { data: [] }

Common Causes:

  1. Returning wrapped response (100%)

Solution:

// ❌ Wrong: Wrapping data unnecessarily
@Get()
findAll() {
const users = this.usersService.findAll();
return { data: users }; // Creates: { data: [...] }
}

// ✅ Right: Return data directly
@Get()
findAll() {
return this.usersService.findAll(); // Returns: [...]
}

// ✅ If you need metadata, include it
@Get()
async findAll(@Query() query: any) {
const users = await this.usersService.findAll(query);
const total = await this.usersService.count();

return {
data: users,
total,
page: query.page,
limit: query.limit
};
}

Check Your Understanding​

Quick Quiz​

  1. What's the difference between @Param() and @Query()?

    Show Answer

    @Param() - Extracts URL path parameters:

    @Get(':id')
    findOne(@Param('id') id: string)
    // URL: /users/123
    // id = "123"

    @Query() - Extracts query string parameters:

    @Get()
    findAll(@Query('page') page: string)
    // URL: /users?page=2
    // page = "2"

    Key difference: Params are part of the route path, queries come after ?

  2. Why is route order important?

    Show Answer

    Routes are matched from top to bottom. If a dynamic route (:id) is defined before a static route (me), the dynamic route will match everything including "me".

    Always put:

    1. Static routes first (/users/me, /users/active)
    2. Dynamic routes last (/users/:id)
    3. Catch-all routes absolutely last (*)
  3. What HTTP method should you use to partially update a resource?

    Show Answer

    PATCH is for partial updates (only specified fields change).

    @Patch(':id')
    update(@Param('id') id: string, @Body() data: Partial<UpdateDto>) {
    // Only update fields provided in data
    }

    PUT is for full replacement (all fields must be provided).

  4. What happens when you throw NotFoundException?

    Show Answer

    NestJS automatically:

    1. Sets response status to 404
    2. Returns JSON error response:
    {
    "statusCode": 404,
    "message": "Not Found",
    "error": "Not Found"
    }
    1. Stops further execution
    2. Sends response to client

    You don't need to manually handle the response.

Hands-On Exercise​

Challenge: Create a books API with full CRUD operations.

Requirements:

  1. Create BooksController with these endpoints:
    • GET /books - List all books
    • GET /books/:id - Get one book
    • GET /books/author/:author - Get books by author
    • POST /books - Create a book
    • PATCH /books/:id - Update a book
    • DELETE /books/:id - Delete a book
  2. Use proper HTTP methods and status codes
  3. Extract parameters correctly
Show Solution
// books.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
HttpCode,
NotFoundException,
} from "@nestjs/common";

interface Book {
id: number;
title: string;
author: string;
year: number;
}

@Controller("books")
export class BooksController {
private books: Book[] = [
{
id: 1,
title: "The Great Gatsby",
author: "F. Scott Fitzgerald",
year: 1925,
},
{ id: 2, title: "1984", author: "George Orwell", year: 1949 },
{ id: 3, title: "To Kill a Mockingbird", author: "Harper Lee", year: 1960 },
];

// GET /books - List all books
@Get()
findAll(@Query("search") search?: string) {
if (search) {
return this.books.filter((book) =>
book.title.toLowerCase().includes(search.toLowerCase())
);
}
return this.books;
}

// GET /books/author/:author - Get books by author (specific route first!)
@Get("author/:author")
findByAuthor(@Param("author") author: string) {
const books = this.books.filter((book) =>
book.author.toLowerCase().includes(author.toLowerCase())
);

if (books.length === 0) {
throw new NotFoundException(`No books found by author: ${author}`);
}

return books;
}

// GET /books/:id - Get one book (dynamic route last)
@Get(":id")
findOne(@Param("id") id: string) {
const numericId = parseInt(id, 10);

if (isNaN(numericId)) {
throw new BadRequestException("ID must be a number");
}

const book = this.books.find((b) => b.id === numericId);

if (!book) {
throw new NotFoundException(`Book with ID ${id} not found`);
}

return book;
}

// POST /books - Create a book
@Post()
@HttpCode(201)
create(@Body() bookData: Omit<Book, "id">) {
const newBook: Book = {
id: Math.max(...this.books.map((b) => b.id)) + 1,
...bookData,
};

this.books.push(newBook);
return newBook;
}

// PATCH /books/:id - Update a book
@Patch(":id")
update(
@Param("id") id: string,
@Body() updateData: Partial<Omit<Book, "id">>
) {
const numericId = parseInt(id, 10);
const bookIndex = this.books.findIndex((b) => b.id === numericId);

if (bookIndex === -1) {
throw new NotFoundException(`Book with ID ${id} not found`);
}

this.books[bookIndex] = {
...this.books[bookIndex],
...updateData,
};

return this.books[bookIndex];
}

// DELETE /books/:id - Delete a book
@Delete(":id")
@HttpCode(204)
remove(@Param("id") id: string) {
const numericId = parseInt(id, 10);
const bookIndex = this.books.findIndex((b) => b.id === numericId);

if (bookIndex === -1) {
throw new NotFoundException(`Book with ID ${id} not found`);
}

this.books.splice(bookIndex, 1);
// No return - 204 means success with no content
}
}

Testing the API:

# List all books
curl http://localhost:3000/books

# Search books
curl http://localhost:3000/books?search=gatsby

# Get one book
curl http://localhost:3000/books/1

# Get books by author
curl http://localhost:3000/books/author/orwell

# Create a book
curl -X POST http://localhost:3000/books \
-H "Content-Type: application/json" \
-d '{"title":"New Book","author":"John Doe","year":2024}'

# Update a book
curl -X PATCH http://localhost:3000/books/1 \
-H "Content-Type: application/json" \
-d '{"year":1926}'

# Delete a book
curl -X DELETE http://localhost:3000/books/1

Explanation:

  • Static route /books/author/:author comes before dynamic /books/:id
  • Proper HTTP methods for each operation
  • Correct status codes (201 for POST, 204 for DELETE)
  • Parameter validation and error handling
  • Search functionality with query parameters

Real-World Controller Patterns​

Let's explore common patterns you'll use in production applications:

Pagination Pattern​

@Controller("posts")
export class PostsController {
@Get()
async findAll(
@Query("page") page: string = "1",
@Query("limit") limit: string = "10",
@Query("sort") sort: string = "createdAt",
@Query("order") order: "ASC" | "DESC" = "DESC"
) {
const pageNum = parseInt(page, 10);
const limitNum = parseInt(limit, 10);

// Validate pagination params
if (pageNum < 1 || limitNum < 1 || limitNum > 100) {
throw new BadRequestException("Invalid pagination parameters");
}

const [posts, total] = await this.postsService.paginate({
page: pageNum,
limit: limitNum,
sort,
order,
});

return {
data: posts,
meta: {
total,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(total / limitNum),
hasNextPage: pageNum * limitNum < total,
hasPrevPage: pageNum > 1,
},
};
}
}

// Usage:
// GET /posts?page=2&limit=20&sort=title&order=ASC

Filtering Pattern​

@Controller("products")
export class ProductsController {
@Get()
async findAll(
@Query("category") category?: string,
@Query("minPrice") minPrice?: string,
@Query("maxPrice") maxPrice?: string,
@Query("inStock") inStock?: string,
@Query("search") search?: string
) {
const filters: any = {};

if (category) filters.category = category;
if (minPrice) filters.minPrice = parseFloat(minPrice);
if (maxPrice) filters.maxPrice = parseFloat(maxPrice);
if (inStock !== undefined) filters.inStock = inStock === "true";
if (search) filters.search = search;

return this.productsService.findAll(filters);
}
}

// Usage:
// GET /products?category=electronics&minPrice=100&maxPrice=1000&inStock=true&search=laptop

Nested Resources Pattern​

@Controller("users/:userId/posts")
export class UserPostsController {
constructor(
private usersService: UsersService,
private postsService: PostsService
) {}

// GET /users/123/posts
@Get()
async findUserPosts(@Param("userId") userId: string) {
const user = await this.usersService.findOne(userId);

if (!user) {
throw new NotFoundException("User not found");
}

return this.postsService.findByUserId(userId);
}

// POST /users/123/posts
@Post()
async createUserPost(
@Param("userId") userId: string,
@Body() postData: CreatePostDto
) {
const user = await this.usersService.findOne(userId);

if (!user) {
throw new NotFoundException("User not found");
}

return this.postsService.create({
...postData,
userId,
});
}

// GET /users/123/posts/456
@Get(":postId")
async findUserPost(
@Param("userId") userId: string,
@Param("postId") postId: string
) {
const post = await this.postsService.findOne(postId);

if (!post) {
throw new NotFoundException("Post not found");
}

// Verify post belongs to user
if (post.userId !== userId) {
throw new NotFoundException("Post not found for this user");
}

return post;
}
}

Bulk Operations Pattern​

@Controller("products")
export class ProductsController {
// Bulk create
@Post("bulk")
async createMany(@Body() products: CreateProductDto[]) {
if (!Array.isArray(products) || products.length === 0) {
throw new BadRequestException("Products array is required");
}

if (products.length > 100) {
throw new BadRequestException(
"Cannot create more than 100 products at once"
);
}

return this.productsService.createMany(products);
}

// Bulk update
@Patch("bulk")
async updateMany(
@Body() updates: { id: number; data: Partial<UpdateProductDto> }[]
) {
return this.productsService.updateMany(updates);
}

// Bulk delete
@Delete("bulk")
@HttpCode(204)
async removeMany(@Body("ids") ids: number[]) {
if (!Array.isArray(ids) || ids.length === 0) {
throw new BadRequestException("IDs array is required");
}

await this.productsService.removeMany(ids);
}
}

File Upload Pattern​

import {
Controller,
Post,
UseInterceptors,
UploadedFile,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";

@Controller("upload")
export class UploadController {
// Single file upload
@Post("single")
@UseInterceptors(FileInterceptor("file"))
uploadFile(@UploadedFile() file: Express.Multer.File) {
return {
filename: file.filename,
originalname: file.originalname,
size: file.size,
mimetype: file.mimetype,
};
}

// Multiple files upload
@Post("multiple")
@UseInterceptors(FilesInterceptor("files", 10))
uploadFiles(@UploadedFiles() files: Express.Multer.File[]) {
return {
count: files.length,
files: files.map((f) => ({
filename: f.filename,
size: f.size,
})),
};
}

// File upload with additional data
@Post("with-data")
@UseInterceptors(FileInterceptor("file"))
uploadWithData(@UploadedFile() file: Express.Multer.File, @Body() data: any) {
return {
file: file.filename,
data,
};
}
}

// Usage:
// curl -X POST http://localhost:3000/upload/single \
// -F "file=@/path/to/file.jpg"

API Versioning Pattern​

// Version 1
@Controller({ version: "1", path: "users" })
export class UsersV1Controller {
@Get()
findAll() {
return { version: 1, users: [] };
}
}

// Version 2 with breaking changes
@Controller({ version: "2", path: "users" })
export class UsersV2Controller {
@Get()
findAll() {
return {
version: 2,
data: [],
meta: { total: 0 },
};
}
}

// Enable versioning in main.ts:
app.enableVersioning({
type: VersioningType.URI,
});

// Access:
// GET /v1/users
// GET /v2/users

Search and Sort Pattern​

@Controller("articles")
export class ArticlesController {
@Get("search")
async search(
@Query("q") query: string,
@Query("fields") fields?: string,
@Query("sort") sort?: string,
@Query("page") page: string = "1",
@Query("limit") limit: string = "10"
) {
if (!query) {
throw new BadRequestException("Search query is required");
}

const searchFields = fields
? fields.split(",")
: ["title", "content", "author"];
const sortOptions = sort
? this.parseSortOptions(sort)
: [{ field: "relevance", order: "DESC" }];

return this.articlesService.search({
query,
fields: searchFields,
sort: sortOptions,
page: parseInt(page),
limit: parseInt(limit),
});
}

private parseSortOptions(sort: string) {
// Parse: "title:asc,date:desc"
return sort.split(",").map((s) => {
const [field, order] = s.split(":");
return { field, order: order?.toUpperCase() || "ASC" };
});
}
}

// Usage:
// GET /articles/search?q=nestjs&fields=title,content&sort=date:desc,title:asc&page=1&limit=20

Performance Implications​

Controller Performance Considerations​

Request/Response Overhead:

// ❌ Inefficient: Processing in controller
@Get()
async findAll() {
const users = await this.usersService.findAll(); // 1000 users

// Processing each user in controller
return users.map(user => ({
...user,
fullName: `${user.firstName} ${user.lastName}`,
age: this.calculateAge(user.birthDate)
}));
}

// ✅ Efficient: Processing in service/database
@Get()
findAll() {
// Service returns processed data
return this.usersService.findAllWithCalculations();
}

Memory Usage with Large Responses:

// ❌ Memory intensive: Loading everything
@Get()
async findAll() {
// Loads 100,000 records into memory
return this.usersService.findAll();
}

// ✅ Memory efficient: Pagination
@Get()
async findAll(
@Query('page') page: string = '1',
@Query('limit') limit: string = '50'
) {
// Loads only 50 records
return this.usersService.paginate(
parseInt(page),
parseInt(limit)
);
}

// ✅ Even better: Streaming for large datasets
@Get('export')
async exportAll(@Res() response: Response) {
response.setHeader('Content-Type', 'text/csv');

const stream = await this.usersService.getStream();
stream.pipe(response);
}

Async Performance:

// ❌ Slow: Sequential operations
@Get(':id')
async getDetails(@Param('id') id: string) {
const user = await this.usersService.findOne(id); // 100ms
const posts = await this.postsService.findByUser(id); // 150ms
const stats = await this.statsService.calculate(id); // 200ms

return { user, posts, stats }; // Total: 450ms
}

// ✅ Fast: Parallel operations
@Get(':id')
async getDetails(@Param('id') id: string) {
const [user, posts, stats] = await Promise.all([
this.usersService.findOne(id),
this.postsService.findByUser(id),
this.statsService.calculate(id)
]);

return { user, posts, stats }; // Total: 200ms (slowest operation)
}

Caching Strategies:

@Controller("products")
export class ProductsController {
// Cache expensive operations
@Get("featured")
@Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes
async getFeatured() {
return this.productsService.findFeatured();
}

// No cache for dynamic data
@Get("cart")
@Header("Cache-Control", "no-store, no-cache, must-revalidate")
async getCart(@Headers("authorization") auth: string) {
const userId = this.authService.getUserId(auth);
return this.cartService.findByUser(userId);
}
}

Summary: Key Takeaways​

🎯 Controller Fundamentals:

  • Controllers handle HTTP requests and return responses
  • Use @Controller() decorator to define base route
  • Keep controllers thin - delegate to services
  • Controllers route requests, services contain logic

🔧 HTTP Methods:

  • @Get() - Retrieve data (idempotent, safe)
  • @Post() - Create new resources (201 status)
  • @Put() - Replace entire resource (all fields required)
  • @Patch() - Partial update (only changed fields)
  • @Delete() - Remove resources (204 status)

📥 Data Extraction:

  • @Param('id') - URL parameters (/users/:id)
  • @Query('page') - Query strings (?page=2)
  • @Body() - Request body (POST/PUT/PATCH)
  • @Headers('auth') - HTTP headers
  • All params and queries are strings - convert manually

🎯 Routing Best Practices:

  • Static routes before dynamic routes
  • Specific routes before generic routes
  • Catch-all routes absolutely last
  • Route order matters - first match wins

⚡ Async Operations:

  • Use async/await for database and API calls
  • Return promises directly when no processing needed
  • Run independent operations in parallel with Promise.all()
  • Handle errors with try/catch or let NestJS handle them

📊 Status Codes:

  • 200 OK - Success
  • 201 Created - Resource created
  • 204 No Content - Success with no response
  • 400 Bad Request - Invalid input
  • 401 Unauthorized - Auth required
  • 403 Forbidden - Not allowed
  • 404 Not Found - Resource doesn't exist
  • 500 Internal Error - Server error

⚠️ Common Pitfalls:

  • ❌ Don't put business logic in controllers
  • ❌ Don't forget params are strings
  • ❌ Don't use @Res() unless necessary
  • ❌ Don't ignore route order
  • ❌ Don't forget to register controllers in modules

✅ Best Practices:

  • ✅ Keep controllers thin and focused
  • ✅ Use appropriate HTTP methods and status codes
  • ✅ Validate input at controller level
  • ✅ Handle errors with exception classes
  • ✅ Use async/await for I/O operations
  • ✅ Return data directly - let NestJS serialize
  • ✅ Use pagination for large datasets
  • ✅ Document your APIs clearly

What's Next?​

Practical Exercises:

  • Build a complete REST API for a todo app
  • Create a blog API with posts, comments, and users
  • Implement search, filtering, and pagination
  • Add error handling and proper status codes

Advanced Topics Coming:

  • Request validation with pipes
  • Authentication and authorization with guards
  • Response transformation with interceptors
  • Error handling with filters
  • File uploads and multipart data

Ready to learn how services work and why dependency injection is so powerful? Let's continue to the Providers article!

Version Information​

Tested with:

  • NestJS: v10.x
  • Node.js: v18.x, v20.x, v22.x
  • TypeScript: v5.x

HTTP Methods Support:

  • Express (default): All methods supported
  • Fastify (optional): All methods supported

Browser Compatibility:

  • All modern browsers support all HTTP methods
  • Older browsers may require polyfills for PATCH

Known Issues:

  • ⚠️ Route order is critical - specific before dynamic
  • ⚠️ @Res() disables automatic serialization
  • ⚠️ Params/queries are always strings
  • ⚠️ Large responses should use streaming or pagination