NextRush
Guides

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 UserController with 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

ScenarioRecommended Style
Small APIs, scriptsFunctional
Large applicationsClass-based
Team projectsClass-based
MicroservicesEither
Prototype/POCFunctional

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/dev

Your 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

Loading diagram...

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

DecoratorHTTP 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:

DecoratorSourceExample
@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 dev

3. 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/users without x-api-key403 Forbidden
  • POST /api/users with valid key → {"id":"...","email":"bob@example.com","name":"Bob"}

Next Steps

On this page