Class-Based Controllers
Build structured APIs with decorators, dependency injection, and automatic route registration
What You Will Build
A REST API using decorator-based controllers, dependency injection, and guards. By the end, you will have:
- A
UserControllerwith CRUD routes wired through decorators - Services and repositories injected automatically via DI
- Route protection with function-based and class-based guards
- A working test setup for controllers
When to Use Class-Based Style
| Scenario | Recommended Style |
|---|---|
| Small APIs, scripts | Functional |
| Large applications | Class-based |
| Team projects | Class-based |
| Microservices | Either |
| Prototype/POC | Functional |
Class-based controllers work best when you need:
- Automatic dependency injection
- Organized route grouping
- Testable architecture
- Team code conventions
Prerequisites
Class-based controllers require decorator metadata. Use the NextRush dev tools:
pnpm add -D @nextrush/devYour tsconfig.json must include:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Installation
$ pnpm add @nextrush/core @nextrush/router @nextrush/adapter-node @nextrush/controllers
@nextrush/decorators, @nextrush/di, and reflect-metadata are installed automatically as dependencies of @nextrush/controllers. The nextrush meta-package auto-imports reflect-metadata.
Quick Example
import { createApp, createRouter, listen } from 'nextrush';
import { Controller, Get, Post, Body, Param, Service, controllersPlugin } from 'nextrush/class';
// Service with DI
@Service()
class UserService {
private users = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
];
findAll() {
return this.users;
}
findById(id: string) {
return this.users.find((u) => u.id === id);
}
create(data: { name: string }) {
const user = { id: String(Date.now()), ...data };
this.users.push(user);
return user;
}
}
// Controller with decorators
@Controller('/users')
class UserController {
constructor(private userService: UserService) {}
@Get()
findAll() {
return this.userService.findAll();
}
@Get('/:id')
findById(@Param('id') id: string) {
return this.userService.findById(id);
}
@Post()
create(@Body() data: { name: string }) {
return this.userService.create(data);
}
}
// Bootstrap
const app = createApp();
const router = createRouter();
await app.plugin(
controllersPlugin({
router,
root: './src', // Auto-discovers all @Controller classes
prefix: '/api',
})
);
app.use(router.routes());
await listen(app, 3000);Import Order Matters
Always import reflect-metadata first, before any other imports. This ensures decorator
metadata is available for all classes.
Understanding the Flow
Dependency Injection
Service Registration
Use @Service() to register a class as a singleton:
import { Service } from '@nextrush/di';
@Service()
class DatabaseService {
async query(sql: string) {
// Database logic
}
}
@Service()
class UserService {
// DatabaseService is automatically injected
constructor(private db: DatabaseService) {}
async findAll() {
return this.db.query('SELECT * FROM users');
}
}Repository Pattern
Use @Repository() for data access classes (semantic alias for @Service()):
import { Repository, Service } from '@nextrush/di';
@Repository()
class UserRepository {
async findById(id: string) {
// Database query
}
}
@Service()
class UserService {
constructor(private repo: UserRepository) {}
}Manual Injection
For interfaces or tokens, use @inject():
import { Service, inject, container } from '@nextrush/di';
interface ILogger {
log(message: string): void;
}
// Register the implementation
container.register('ILogger', { useValue: console });
@Service()
class UserService {
constructor(@inject('ILogger') private logger: ILogger) {}
}Scopes
By default, services are singletons. For transient (new instance per resolution):
@Service({ scope: 'transient' })
class RequestScopedService {
readonly createdAt = Date.now();
}Controller Decorators
@Controller
Groups routes under a common prefix:
@Controller('/api/v1/users')
class UserController {
// All routes start with /api/v1/users
}Route Decorators
| Decorator | HTTP Method |
|---|---|
@Get(path?) | GET |
@Post(path?) | POST |
@Put(path?) | PUT |
@Patch(path?) | PATCH |
@Delete(path?) | DELETE |
@Head(path?) | HEAD |
@Options(path?) | OPTIONS |
@All(path?) | All methods |
@Controller('/products')
class ProductController {
@Get() // GET /products
findAll() {}
@Get('/:id') // GET /products/:id
findById() {}
@Post() // POST /products
create() {}
@Put('/:id') // PUT /products/:id
update() {}
@Delete('/:id') // DELETE /products/:id
remove() {}
}Parameter Decorators
Extract data from the request:
| Decorator | Source | Example |
|---|---|---|
@Body() | Request body | @Body() data: CreateUserDto |
@Body('field') | Body field | @Body('email') email: string |
@Param() | All route params | @Param() params: { id: string } |
@Param('name') | Single param | @Param('id') id: string |
@Query() | All query params | @Query() query: { page: string } |
@Query('name') | Single query param | @Query('page') page: string |
@Header() | All headers | @Header() headers: Record<string, string> |
@Header('name') | Single header | @Header('authorization') auth: string |
@Ctx() | Full context | @Ctx() ctx: Context |
@Controller('/users')
class UserController {
@Get('/:id')
findById(
@Param('id') id: string,
@Query('include') include?: string,
@Header('authorization') auth?: string
) {
// id from URL path
// include from query string
// auth from request header
}
@Post()
create(@Body() data: CreateUserDto, @Ctx() ctx: Context) {
// data is parsed request body
// ctx is full context if needed
}
}Response Decorators
Set headers or redirect responses declaratively on route methods.
@SetHeader
import { Controller, Get, SetHeader } from '@nextrush/decorators';
@Controller('/api')
class ApiController {
@SetHeader('Cache-Control', 'no-store')
@SetHeader('X-Custom', 'value')
@Get('/data')
getData() {
return { ok: true };
// Response automatically includes Cache-Control and X-Custom headers
}
}@Redirect
import { Controller, Get, Redirect } from '@nextrush/decorators';
@Controller('/legacy')
class LegacyController {
@Redirect('/new-dashboard', 301)
@Get('/dashboard')
oldDashboard() {
// Return a string to override the redirect URL
// Return { url, statusCode } to override both
}
}Custom Parameter Decorators
Create reusable parameter extractors with createCustomParamDecorator:
import { createCustomParamDecorator, Controller, Get } from '@nextrush/decorators';
import type { Context } from '@nextrush/types';
// Create a decorator that extracts the current user from state
const CurrentUser = createCustomParamDecorator((ctx: Context) => ctx.state.user);
// Create a decorator that extracts a specific cookie
const Cookie = (name: string) =>
createCustomParamDecorator((ctx: Context) => {
const cookies = ctx.get('cookie') ?? '';
const match = cookies.split(';').find((c) => c.trim().startsWith(`${name}=`));
return match?.split('=')[1]?.trim();
});
@Controller('/profile')
class ProfileController {
@Get()
getProfile(@CurrentUser user: User, @Cookie('theme') theme?: string) {
return { user, theme };
}
}Optional Dependencies
Mark constructor parameters as optional with @Optional():
import { Service, Optional } from '@nextrush/di';
@Service()
class AppService {
constructor(
private required: DatabaseService,
@Optional() private cache?: CacheService
) {
// cache is undefined if CacheService is not registered
}
}Guards
Guards protect routes by determining if a request should proceed.
Function Guards
import { UseGuard, type GuardFn } from '@nextrush/decorators';
const AuthGuard: GuardFn = async (ctx) => {
const token = ctx.get('authorization');
if (!token) return false;
// Verify token and attach user to state
const user = await verifyToken(token);
ctx.state.user = user;
return true;
};
@UseGuard(AuthGuard)
@Controller('/users')
class UserController {
@Get()
findAll() {
// Only executed if AuthGuard returns true
}
}Guard Factory
Create configurable guards:
const RoleGuard =
(roles: string[]): GuardFn =>
async (ctx) => {
const user = ctx.state.user as { role: string } | undefined;
return user ? roles.includes(user.role) : false;
};
@UseGuard(AuthGuard)
@UseGuard(RoleGuard(['admin']))
@Controller('/admin')
class AdminController {
@Get('/dashboard')
dashboard() {
return { admin: true };
}
}Class Guards (with DI)
Guards can also be classes with dependencies:
import { type CanActivate, type GuardContext } from '@nextrush/decorators';
import { Service } from '@nextrush/di';
@Service()
class AuthGuard implements CanActivate {
constructor(private authService: AuthService) {}
async canActivate(ctx: GuardContext): Promise<boolean> {
const token = ctx.get('authorization');
if (!token) return false;
const user = await this.authService.verify(token);
ctx.state.user = user;
return Boolean(user);
}
}
@UseGuard(AuthGuard) // Class reference, resolved from DI
@Controller('/protected')
class ProtectedController {}Guard Execution Order
Guards run in order: class guards first, then method guards.
@UseGuard(ClassGuard1) // Runs 1st
@UseGuard(ClassGuard2) // Runs 2nd
@Controller('/example')
class ExampleController {
@UseGuard(MethodGuard1) // Runs 3rd
@UseGuard(MethodGuard2) // Runs 4th
@Get()
handler() {}
}If any guard returns false, NextRush rejects the request with 403 Forbidden.
Complete Example
Here's a complete REST API with authentication.
This example also uses @nextrush/cors and @nextrush/body-parser — install them separately:
$ pnpm add @nextrush/cors @nextrush/body-parser
Full authenticated REST API example
import { createApp, createRouter, listen } from 'nextrush';
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuard,
Service,
Repository,
controllersPlugin,
} from 'nextrush/class';
import { json } from '@nextrush/body-parser';
import { cors } from '@nextrush/cors';
import type { GuardFn } from 'nextrush/class';
// ===== Domain Types =====
interface User {
id: string;
email: string;
name: string;
}
interface CreateUserDto {
email: string;
name: string;
}
// ===== Repository =====
@Repository()
class UserRepository {
private users: User[] = [{ id: '1', email: 'alice@example.com', name: 'Alice' }];
findAll() {
return this.users;
}
findById(id: string) {
return this.users.find((u) => u.id === id);
}
create(data: CreateUserDto): User {
const user: User = { id: String(Date.now()), ...data };
this.users.push(user);
return user;
}
update(id: string, data: Partial<CreateUserDto>) {
const user = this.findById(id);
if (user) Object.assign(user, data);
return user;
}
delete(id: string) {
const index = this.users.findIndex((u) => u.id === id);
if (index >= 0) this.users.splice(index, 1);
}
}
// ===== Service =====
@Service()
class UserService {
constructor(private repo: UserRepository) {}
findAll() {
return this.repo.findAll();
}
findById(id: string) {
const user = this.repo.findById(id);
if (!user) throw new Error('User not found');
return user;
}
create(data: CreateUserDto) {
return this.repo.create(data);
}
update(id: string, data: Partial<CreateUserDto>) {
return this.repo.update(id, data);
}
delete(id: string) {
this.repo.delete(id);
}
}
// ===== Guard =====
const AuthGuard: GuardFn = (ctx) => {
const apiKey = ctx.get('x-api-key');
return apiKey === 'secret-key';
};
// ===== Controller =====
@UseGuard(AuthGuard)
@Controller('/api/users')
class UserController {
constructor(private userService: UserService) {}
@Get()
findAll() {
return this.userService.findAll();
}
@Get('/:id')
findById(@Param('id') id: string) {
return this.userService.findById(id);
}
@Post()
create(@Body() data: CreateUserDto) {
return this.userService.create(data);
}
@Put('/:id')
update(@Param('id') id: string, @Body() data: Partial<CreateUserDto>) {
return this.userService.update(id, data);
}
@Delete('/:id')
delete(@Param('id') id: string) {
this.userService.delete(id);
return { deleted: true };
}
}
// ===== Bootstrap =====
const app = createApp();
const router = createRouter();
// Global middleware
app.use(cors());
app.use(json());
// Auto-discover all @Controller classes in ./src
await app.plugin(
controllersPlugin({
router,
root: './src',
prefix: '/api',
})
);
app.use(router.routes());
await listen(app, 3000);Testing Controllers
Controllers are testable by design — dependencies are injected through the constructor:
import { describe, it, expect, beforeEach } from 'vitest';
import { createContainer } from '@nextrush/di';
import { UserController } from './user.controller';
import { UserService } from './user.service';
describe('UserController', () => {
let controller: UserController;
let mockService: Partial<UserService>;
beforeEach(() => {
// Create mock service
mockService = {
findAll: () => [{ id: '1', name: 'Test' }],
findById: (id) => ({ id, name: 'Test' }),
};
// Create isolated container
const container = createContainer();
container.register(UserService, { useValue: mockService as UserService });
container.register(UserController, { useClass: UserController });
// Resolve controller
controller = container.resolve(UserController);
});
it('should return all users', () => {
const result = controller.findAll();
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Test');
});
it('should find user by id', () => {
const result = controller.findById('123');
expect(result.id).toBe('123');
});
});Common Mistakes
1. Missing reflect-metadata Import
// ❌ Wrong - decorators fail silently
import { Service } from '@nextrush/di';
@Service()
class MyService {}
// ✅ Correct - use nextrush meta-package (auto-imports reflect-metadata)
import { Service } from 'nextrush/class';
@Service()
class MyService {}Or if using individual packages:
import 'reflect-metadata';
import { Service } from '@nextrush/di';
@Service()
class MyService {}2. Using Wrong Build Tool
# ❌ Wrong - no decorator metadata
npx tsx src/index.ts
# ✅ Correct - proper decorator metadata
npx nextrush dev3. Circular Dependencies
// ❌ Wrong - circular dependency error
@Service()
class ServiceA {
constructor(private b: ServiceB) {}
}
@Service()
class ServiceB {
constructor(private a: ServiceA) {} // Circular!
}
// ✅ Correct - use delay() for circular deps
import { delay, inject } from '@nextrush/di';
@Service()
class ServiceA {
constructor(@inject(delay(() => ServiceB)) private b: ServiceB) {}
}4. Forgetting to Register Controller
// ❌ Wrong - no root provided, nothing will be discovered
await app.plugin(
controllersPlugin({
router,
// Missing root: './src'
})
);
// ✅ Correct - auto-discover all controllers
await app.plugin(
controllersPlugin({
router,
root: './src', // Scans all .ts/.js files recursively
prefix: '/api',
})
);Verification
After starting the server, verify your routes work:
# List users
curl http://localhost:3000/api/users
# Get a single user
curl http://localhost:3000/api/users/1
# Create a user (with API key header)
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-H "x-api-key: secret-key" \
-d '{"email": "bob@example.com", "name": "Bob"}'Expected responses:
GET /api/users→[{"id":"1","email":"alice@example.com","name":"Alice"}]GET /api/users/1→{"id":"1","email":"alice@example.com","name":"Alice"}POST /api/userswithoutx-api-key→403 ForbiddenPOST /api/userswith valid key →{"id":"...","email":"bob@example.com","name":"Bob"}
Next Steps
Dev Tools
Set up development and build tools for decorators.
DI & Decorators Architecture
Understand how DI and decorators work internally.
Testing
Test your controllers and services.
Controllers Package
Full API reference for the controllers plugin.
Security Best Practices
Helmet, CORS, rate limiting, and input validation.