Controllers

Building APIs with Controllers in Vercube

Controllers are the heart of your API in Vercube. They organize your endpoints into logical groups, handle incoming requests, and return responses. Think of controllers as the "traffic directors" of your application - they receive requests, process them, and send back responses.

What is a Controller?

A controller is a class that groups related HTTP endpoints together. Instead of scattering your API endpoints across different files, you organize them by resource or feature. For example, all user-related operations go in UserController, all product operations go in ProductController, and so on.

The Problem Without Controllers

// ❌ Scattered endpoints - hard to maintain
app.get('/users', getUsersHandler);
app.post('/users', createUserHandler);
app.get('/users/:id', getUserHandler);
app.delete('/users/:id', deleteUserHandler);
app.put('/users/:id', updateUserHandler);
// ... hundreds of lines later
app.get('/products', getProductsHandler);
// ... where does one resource end and another begin?

The Solution With Controllers

// ✅ Organized, clean, maintainable
@Controller('/users')
export class UserController {
  @Get('/')
  getUsers() { }
  
  @Post('/')
  createUser() { }
  
  @Get('/:id')
  getUser() { }
  
  @Put('/:id')
  updateUser() { }
  
  @Delete('/:id')
  deleteUser() { }
}

How Controllers Work

Here's the complete flow from request to response:

Behind the Scenes

When you create a controller with decorators, Vercube automatically:

  1. Registers routes - Combines controller path with method paths
  2. Creates handlers - Wraps your methods in proper HTTP handlers
  3. Resolves parameters - Extracts data from URL, body, headers, etc.
  4. Handles responses - Serializes your return value to JSON
  5. Manages errors - Catches and formats errors appropriately

Creating Your First Controller

Step 1: Create the Controller Class

UserController.ts
import { Controller } from '@vercube/core';

@Controller('/users')
export class UserController {
  // Your endpoints will go here
}

The @Controller('/users') decorator does two things:

  • Marks this class as a controller
  • Sets /users as the base path for all routes

Step 2: Add Route Handlers

UserController.ts
import { Controller, Get } from '@vercube/core';

@Controller('/users')
export class UserController {
  
  @Get('/')
  getAllUsers() {
    return [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ];
  }
}

This creates: GET /users/

Vercube automatically:

  • Converts the return value to JSON
  • Sets Content-Type: application/json
  • Sends status code 200

Step 3: Register the Controller

setup.ts
import { type App } from '@vercube/core';
import { UserController } from './controllers/UserController';

export function setup(app: App): void {
  app.useController(UserController);
}

That's it! Your API is ready to handle requests.

HTTP Methods

Vercube provides decorators for all standard HTTP methods:

import { Controller, Get, Post, Put, Patch, Delete } from '@vercube/core';

@Controller('/users')
export class UserController {
  
  @Get('/')
  list() {
    // GET /users - List all users
  }
  
  @Get('/:id')
  get() {
    // GET /users/:id - Get specific user
  }
  
  @Post('/')
  create() {
    // POST /users - Create new user
  }
  
  @Put('/:id')
  update() {
    // PUT /users/:id - Replace user
  }
  
  @Patch('/:id')
  modify() {
    // PATCH /users/:id - Partially update user
  }
  
  @Delete('/:id')
  remove() {
    // DELETE /users/:id - Delete user
  }
}

Path Parameters

Extract dynamic values from URLs using the @Param decorator:

import { Controller, Get, Param } from '@vercube/core';

@Controller('/users')
export class UserController {
  
  @Get('/:id')
  getUserById(@Param('id') id: string) {
    return { id, name: 'User ' + id };
  }
  
  @Get('/:userId/posts/:postId')
  getUserPost(
    @Param('userId') userId: string,
    @Param('postId') postId: string
  ) {
    return { userId, postId, title: 'Post title' };
  }
}

Request Body

Access request body data with the @Body decorator:

import { Controller, Post, Body } from '@vercube/core';

interface CreateUserDto {
  name: string;
  email: string;
  age: number;
}

@Controller('/users')
export class UserController {
  
  @Post('/')
  createUser(@Body() userData: CreateUserDto) {
    // userData is automatically parsed from JSON
    console.log(userData.name);   // "Alice"
    console.log(userData.email);  // "[email protected]"
    
    return { id: 1, ...userData };
  }
}
# Client sends:
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"[email protected]","age":25}'
The @Body() decorator automatically parses JSON request bodies. No manual JSON.parse() needed!
You can validate and transform request body automatically using Zod schemas. This ensures type safety and data integrity. Learn more in the Validation guide.
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(18)
});

type CreateUserDto = z.infer<typeof CreateUserSchema>;

@Post('/')
createUser(@Body({ validationSchema: CreateUserSchema }) userData: CreateUserDto) {
  // userData is validated and type-safe!
  // If validation fails, automatic 400 Bad Request response
}

Query Parameters

Extract URL query parameters using @QueryParam or @QueryParams:

import { Controller, Get, QueryParam, QueryParams } from '@vercube/core';

@Controller('/users')
export class UserController {
  
  @Get('/search')
  searchUsers(
    @QueryParam('name') name: string,
    @QueryParam('minAge') minAge: string,  // Always string!
    @QueryParams() allParams: Record<string, string>  // Always strings!
  ) {
    // Convert to number if needed
    const minAgeNumber = parseInt(minAge, 10);
    
    return {
      searching: { name, minAge: minAgeNumber },
      allQueryParams: allParams
    };
  }
}
# Request:
GET /users/search?name=Alice&minAge=21&city=NYC

# Your method receives:
# name = "Alice"  (string)
# minAge = "21"   (string, not number!)
# allParams = { name: "Alice", minAge: "21", city: "NYC" }  (all strings)
Important: Query parameters are ALWAYS strings according to the URL specification. Even if the value looks like a number (?age=25), you'll receive it as the string "25". You must manually convert to numbers, booleans, or other types:
@Get('/search')
searchUsers(
  @QueryParam('age') age: string,
  @QueryParam('active') active: string
) {
  const ageNumber = parseInt(age, 10);           // "25" → 25
  const isActive = active === 'true';            // "true" → true
}
Better approach: Use Zod schemas to automatically validate and transform query parameters to the correct types. This eliminates manual parsing and ensures type safety. Learn more in the Validation guide.
import { z } from 'zod';

const SearchUsersSchema = z.object({
  age: z.coerce.number().min(0),      // Automatically converts string to number!
  active: z.coerce.boolean(),         // Automatically converts string to boolean!
  name: z.string().optional()
});

type SearchUsersDto = z.infer<typeof SearchUsersSchema>;

@Get('/search')
searchUsers(@QueryParams({ validationSchema: SearchUsersSchema }) query: SearchUsersDto) {
  // query.age is now a number: 25
  // query.active is now a boolean: true
  // No manual conversion needed!
}

Headers

Access request headers with @Header or @Headers:

import { Controller, Get, Header, Headers } from '@vercube/core';

@Controller('/users')
export class UserController {
  
  @Get('/')
  getUsers(
    @Header('Authorization') token: string,
    @Header('X-API-Key') apiKey: string,
    @Headers() allHeaders: Record<string, string>
  ) {
    console.log('Token:', token);
    console.log('API Key:', apiKey);
    
    return { authenticated: !!token };
  }
}

Returning Responses

You have multiple ways to return responses from your controllers:

Simple Values (Auto JSON)

@Get('/')
getUser() {
  // Automatically converted to JSON with status 200
  return { id: 1, name: 'Alice' };
}

Custom Status Code

import { Status } from '@vercube/core';

@Post('/')
@Status(201)
createUser(@Body() data: any) {
  // Returns status 201 Created
  return { id: 1, ...data };
}

Custom Headers

import { SetHeader } from '@vercube/core';

@Get('/')
@SetHeader('X-Custom-Header', 'value')
@SetHeader('X-Rate-Limit', '1000')
getUsers() {
  return [{ id: 1, name: 'Alice' }];
}

Redirects

import { Redirect } from '@vercube/core';

@Get('/old-endpoint')
@Redirect('/new-endpoint', 301)
oldEndpoint() {
  // Automatically redirects to /new-endpoint
}

Manual Response Control

import { Response } from '@vercube/core';

@Get('/')
getUsers(@Response() res: Response) {
  res.headers.set('X-Custom', 'value');
  
  return new Response(
    JSON.stringify({ data: [] }),
    { 
      status: 200,
      headers: { 'Content-Type': 'application/json' }
    }
  );
}

Working with Services

Controllers should be thin - they coordinate but don't contain business logic. Use dependency injection to access services:

import { Controller, Get, Inject } from '@vercube/core';
import { UserService } from '../services/UserService';

@Controller('/users')
export class UserController {
  
  @Inject(UserService)
  private userService!: UserService;
  
  @Get('/')
  async getAllUsers() {
    // Business logic is in the service
    return await this.userService.findAll();
  }
  
  @Get('/:id')
  async getUserById(@Param('id') id: string) {
    return await this.userService.findById(id);
  }
  
  @Post('/')
  async createUser(@Body() data: CreateUserDto) {
    return await this.userService.create(data);
  }
}

Async Handlers

Controllers fully support async/await:

@Controller('/users')
export class UserController {
  
  @Inject(UserService)
  private userService!: UserService;
  
  @Get('/:id')
  async getUser(@Param('id') id: string) {
    // Await is handled automatically
    const user = await this.userService.findById(id);
    
    if (!user) {
      throw new NotFoundException('User not found');
    }
    
    return user;
  }
  
  @Post('/')
  async createUser(@Body() data: CreateUserDto) {
    // Multiple awaits work perfectly
    await this.userService.validateEmail(data.email);
    const user = await this.userService.create(data);
    await this.userService.sendWelcomeEmail(user);
    
    return user;
  }
}

Error Handling

Vercube provides HTTP exception classes for common errors:

import { 
  NotFoundException, 
  BadRequestException,
  UnauthorizedException,
  ForbiddenException
} from '@vercube/core';

@Controller('/users')
export class UserController {
  
  @Get('/:id')
  async getUser(@Param('id') id: string) {
    const user = await this.userService.findById(id);
    
    if (!user) {
      throw new NotFoundException('User not found');
    }
    
    return user;
  }
  
  @Post('/')
  createUser(@Body() data: CreateUserDto) {
    if (!data.email) {
      throw new BadRequestException('Email is required');
    }
    
    if (data.age < 18) {
      throw new ForbiddenException('Must be 18 or older');
    }
    
    return this.userService.create(data);
  }
}

These exceptions are automatically converted to proper HTTP responses:

// NotFoundException → 404
{
  "statusCode": 404,
  "message": "User not found",
  "error": "Not Found"
}

// BadRequestException → 400
{
  "statusCode": 400,
  "message": "Email is required",
  "error": "Bad Request"
}

Middleware Integration

Apply middleware to controllers or specific routes:

import { Controller, Get, Middleware } from '@vercube/core';
import { AuthMiddleware, LoggingMiddleware } from '../middlewares';

// Applies to ALL routes in this controller
@Controller('/users')
@Middleware(AuthMiddleware)
export class UserController {
  
  @Get('/')
  getUsers() {
    // Protected by AuthMiddleware
  }
  
  // Additional middleware for this route only
  @Get('/:id')
  @Middleware(LoggingMiddleware)
  getUserById() {
    // Protected by AuthMiddleware + LoggingMiddleware
  }
}

Organizing Controllers

src/
├── controllers/
│   ├── UserController.ts       # All user operations
│   ├── ProductController.ts    # All product operations
│   ├── OrderController.ts      # All order operations
│   └── AuthController.ts       # All auth operations

By Feature

src/
├── features/
│   ├── users/
│   │   ├── UserController.ts
│   │   ├── UserService.ts
│   │   └── User.model.ts
│   └── products/
│       ├── ProductController.ts
│       ├── ProductService.ts
│       └── Product.model.ts

Best Practices

Keep controllers thin - Business logic belongs in services

// ❌ Bad - logic in controller
@Get('/:id')
async getUser(@Param('id') id: string) {
  const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
  delete user.password;
  user.fullName = user.firstName + ' ' + user.lastName;
  return user;
}

// ✅ Good - logic in service
@Get('/:id')
async getUser(@Param('id') id: string) {
  return await this.userService.findById(id);
}

Use DTOs for request validation - Type your inputs

// ✅ Good
interface CreateUserDto {
  name: string;
  email: string;
  age: number;
}

@Post('/')
createUser(@Body() data: CreateUserDto) {
  // TypeScript ensures data structure
}

One responsibility per controller - Don't mix concerns

// ❌ Bad - mixed concerns
@Controller('/api')
export class ApiController {
  @Get('/users') getUsers() { }
  @Get('/products') getProducts() { }
  @Get('/orders') getOrders() { }
}

// ✅ Good - focused controllers
@Controller('/users')
export class UserController { }

@Controller('/products')
export class ProductController { }

Use meaningful route names - Be clear about what each endpoint does

// ❌ Unclear
@Get('/data')
getData() { }

// ✅ Clear
@Get('/users')
getAllUsers() { }

Common Patterns

CRUD Controller Template

@Controller('/users')
export class UserController {
  @Inject(UserService)
  private userService!: UserService;
  
  @Get('/')
  async findAll() {
    return await this.userService.findAll();
  }
  
  @Get('/:id')
  async findOne(@Param('id') id: string) {
    return await this.userService.findById(id);
  }
  
  @Post('/')
  @Status(201)
  async create(@Body() data: CreateUserDto) {
    return await this.userService.create(data);
  }
  
  @Put('/:id')
  async update(
    @Param('id') id: string,
    @Body() data: UpdateUserDto
  ) {
    return await this.userService.update(id, data);
  }
  
  @Delete('/:id')
  @Status(204)
  async remove(@Param('id') id: string) {
    await this.userService.remove(id);
  }
}

Nested Resources

@Controller('/users/:userId/posts')
export class UserPostController {
  
  @Get('/')
  getUserPosts(@Param('userId') userId: string) {
    // GET /users/123/posts
  }
  
  @Post('/')
  createUserPost(
    @Param('userId') userId: string,
    @Body() data: CreatePostDto
  ) {
    // POST /users/123/posts
  }
}