Controllers
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:
- Registers routes - Combines controller path with method paths
- Creates handlers - Wraps your methods in proper HTTP handlers
- Resolves parameters - Extracts data from URL, body, headers, etc.
- Handles responses - Serializes your return value to JSON
- Manages errors - Catches and formats errors appropriately
Creating Your First Controller
Step 1: Create the Controller Class
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
/usersas the base path for all routes
Step 2: Add Route Handlers
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
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}'
@Body() decorator automatically parses JSON request bodies. No manual JSON.parse() needed!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)
?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
}
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
By Resource (Recommended)
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
}
}