NextRush

@nextrush/controllers

Auto-discovery, dependency injection, and decorator-based controllers for scalable APIs.

Build scalable APIs with automatic controller discovery and dependency injection.

The controllers plugin scans your source directory for @Controller decorated classes, resolves their dependencies, and registers routes automatically. No manual wiring required.


Why Auto-Discovery?

In production applications with dozens of controllers, manual registration creates maintenance burden:

// ❌ Manual registration doesn't scale
app.plugin(
  controllersPlugin({
    controllers: [
      UserController,
      PostController,
      CommentController,
      AuthController,
      AdminController,
      // ...50 more controllers
    ],
  })
);

Auto-discovery solves this:

// ✅ Auto-discovery scales to any size
await app.plugin(
  controllersPlugin({
    router,
    root: './src',
    prefix: '/api/v1',
  })
);

Add a controller file anywhere in ./src — any name, any subdirectory. Decorate the class with @Controller and it is automatically discovered and registered. No imports, no arrays, no naming convention enforced.


Installation

If you use the nextrush meta-package, everything is included:

$ pnpm add nextrush

Or install individual packages:

$ pnpm add @nextrush/controllers @nextrush/decorators @nextrush/di

The nextrush meta-package auto-imports reflect-metadata. If using individual packages, install reflect-metadata separately and import it first in your entry point.

You need @nextrush/decorators for @Controller, route, and parameter decorators, and @nextrush/di for @Service and the DI container.


Quick Start

src/index.ts
import { createApp, createRouter, listen } from 'nextrush';
import { controllersPlugin } from 'nextrush/class';

async function main() {
  const app = createApp();
  const router = createRouter();

  // Auto-discover all @Controller classes in ./src
  // Any file name, any subdirectory — no naming convention required
  await app.plugin(
    controllersPlugin({
      router,
      root: './src',
      prefix: '/api',
    })
  );

  app.route('/', router);

  await listen(app, 3000);
}

main();
src/controllers/user.controller.ts
import { Controller, Get, Post, Body } from '@nextrush/decorators';
import { Service } from '@nextrush/di';

@Service()
class UserService {
  private users = [{ id: '1', name: 'Alice' }];

  findAll() {
    return this.users;
  }

  create(name: string) {
    const user = { id: Date.now().toString(), name };
    this.users.push(user);
    return user;
  }
}

@Controller('/users')
export class UserController {
  constructor(private userService: UserService) {}

  @Get()
  findAll() {
    return { data: this.userService.findAll() };
  }

  @Post()
  create(@Body('name') name: string) {
    return { data: this.userService.create(name) };
  }
}

When the app starts with debug: true:

[Controllers] Starting auto-discovery in: ./src
[Controllers] Discovered: UserController from ./src/controllers/user.controller.ts
[Controllers] Registered: UserController
  GET     /api/users
  POST    /api/users
[Controllers] Initialized with 2 routes

Plugin Options

The controllersPlugin() factory accepts these options:

Required

PropertyTypeDescription
routerRouterRouter instance for route registration

Auto-Discovery

PropertyTypeDescription
root?stringRoot directory to scan for controllers. Enables auto-discovery when provided.
includestring[]= ['**/*.ts', '**/*.js']Glob patterns to include in auto-discovery
excludestring[]= ['**/*.test.ts', '**/*.spec.ts', '**/*.test.js', '**/*.spec.js', '**/node_modules/**', '**/dist/**', '**/__tests__/**']Glob patterns to exclude from auto-discovery
strictboolean= falseThrow on discovery errors instead of logging warnings

Route Configuration

PropertyTypeDescription
prefixstring= ''Route prefix applied to all controllers
middleware?Middleware[]Global middleware applied to all routes. Can also accept DI tokens (string or Symbol) that resolve to middleware from the container.

Advanced

PropertyTypeDescription
container?ContainerInterfaceCustom DI container. Uses the global container if not provided.
debugboolean= falseEnable debug logging for discovery and registration
controllers?Function[]Manual controller array (deprecated — prefer auto-discovery with root)

Discovery Patterns

Default Patterns

By default, the plugin scans for all .ts and .js files, excluding tests and build artifacts:

await app.plugin(
  controllersPlugin({
    router,
    root: './src',
    // Default patterns:
    // include: ['**/*.ts', '**/*.js']
    // exclude: ['**/*.test.ts', '**/*.spec.ts', '**/*.test.js', '**/*.spec.js',
    //           '**/node_modules/**', '**/dist/**', '**/__tests__/**']
  })
);

Custom Patterns

The include option is not required. By default the plugin scans all .ts and .js files under root — it does not care how files are named. Use include only to narrow the scan when needed (e.g. to skip a large src/generated/ folder or enforce team conventions).

Narrow the scan to specific directories or naming conventions:

// Only scan controllers directory
await app.plugin(
  controllersPlugin({
    router,
    root: './src',
    include: ['controllers/**/*.ts'],
  })
);

// Use naming convention
await app.plugin(
  controllersPlugin({
    router,
    root: './src',
    include: ['**/*.controller.ts', 'modules/**/*.controller.ts'],
  })
);

// Feature-based structure
await app.plugin(
  controllersPlugin({
    router,
    root: './src/features',
    include: ['**/controller.ts', '**/routes.ts'],
  })
);

Decorators

Import decorators from @nextrush/decorators.

Controller Decorator

@Controller('/users')
export class UserController {
  // All routes inherit /users prefix
}

// With version prefix
@Controller({ prefix: '/users', version: 'v2' })
export class UserControllerV2 {
  // Routes prefixed with /v2/users
}

Route Decorators

@Controller('/items')
export class ItemController {
  @Get() // GET /items
  list() {}

  @Get('/:id') // GET /items/:id
  findOne() {}

  @Post() // POST /items
  create() {}

  @Put('/:id') // PUT /items/:id
  replace() {}

  @Patch('/:id') // PATCH /items/:id
  update() {}

  @Delete('/:id') // DELETE /items/:id
  remove() {}
}

Parameter Decorators

@Controller('/users')
export class UserController {
  @Post()
  create(
    @Body() data: CreateUserDto, // Full request body
    @Body('name') name: string, // Specific body field
    @Param('id') id: string, // Route parameter
    @Query() filters: Record<string, string>, // All query params
    @Query('page') page: string, // Specific query param
    @Header('authorization') auth: string, // Request header
    @Ctx() ctx: Context // Full context object
  ) {}
}

Parameter Transform

Apply transformations or validation to parameters:

import { z } from 'zod';

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

@Controller('/users')
export class UserController {
  @Post()
  create(@Body({ transform: CreateUserSchema.parse }) data: z.infer<typeof CreateUserSchema>) {
    // data is validated and typed
    return { user: data };
  }

  @Get('/:id')
  findOne(@Param('id', { transform: Number }) id: number) {
    // id is converted to number
    return { id };
  }
}

Custom Parameter Decorators

Use createCustomParamDecorator() from @nextrush/decorators to create reusable parameter extractors:

import { createCustomParamDecorator } from '@nextrush/decorators';
import type { Context } from '@nextrush/types';

const CurrentUser = createCustomParamDecorator((ctx: Context) => ctx.state.user);

@Controller('/users')
export class UserController {
  @Get('/me')
  getProfile(@CurrentUser user: User) {
    return user;
  }
}

Custom parameters use the 'custom' source type internally and are fully supported by the handler builder.

Response Decorators

Set response headers or redirect behavior declaratively:

import { SetHeader, Redirect, Controller, Get } from '@nextrush/decorators';

@Controller('/api')
export class ApiController {
  @SetHeader('Cache-Control', 'no-store')
  @SetHeader('X-Request-Id', 'auto')
  @Get('/data')
  getData() {
    return { result: 'ok' };
    // Response includes Cache-Control and X-Request-Id headers
  }

  @Redirect('/new-page', 301)
  @Get('/old-page')
  oldPage() {
    // Return string to override URL, or { url, statusCode } to override both
  }
}

Headers from @SetHeader are precomputed at build time and applied before the handler response is sent. @Redirect sets the Location header and sends an empty body.


Guards

Guards protect routes by returning a boolean to allow or deny access.

Function Guards

import type { GuardFn } from '@nextrush/decorators';

const AuthGuard: GuardFn = async (ctx) => {
  const token = ctx.headers['authorization'];
  if (!token?.startsWith('Bearer ')) {
    return false;
  }

  const user = await verifyToken(token.slice(7));
  if (!user) return false;

  ctx.state.user = user;
  return true;
};

@UseGuard(AuthGuard)
@Controller('/protected')
export class ProtectedController {
  @Get()
  secret() {
    return { secret: 'data' };
  }
}

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', 'moderator']))
@Controller('/admin')
export class AdminController {
  @Get('/dashboard')
  dashboard() {
    return { admin: true };
  }
}

Class Guards (with DI)

For guards that need dependencies:

import { Service } from '@nextrush/di';
import type { CanActivate, GuardContext } from '@nextrush/decorators';

@Service()
class PermissionGuard implements CanActivate {
  constructor(private permissionService: PermissionService) {}

  async canActivate(ctx: GuardContext): Promise<boolean> {
    const user = ctx.state.user;
    return this.permissionService.check(user, ctx.path);
  }
}

@UseGuard(PermissionGuard) // Resolved from DI container
@Controller('/resources')
export class ResourceController {}

Dependency Injection

Import DI decorators from @nextrush/di.

Service Registration

import { Service, Repository } from '@nextrush/di';

// Singleton by default
@Service()
class UserService {
  constructor(private repo: UserRepository) {}
}

// Transient (new instance per resolution)
@Service({ scope: 'transient' })
class RequestLogger {}

// Semantic alias for data access
@Repository()
class UserRepository {
  async find() {
    return [{ id: 1, name: 'Alice' }];
  }
}

Custom Container

import { createContainer } from '@nextrush/di';
import { controllersPlugin } from '@nextrush/controllers';

const container = createContainer();

// Register configuration
container.register('CONFIG', {
  useValue: { apiKey: process.env.API_KEY },
});

await app.plugin(
  controllersPlugin({
    router,
    root: './src',
    container,
  })
);

Error Handling

The plugin provides specific error types:

import {
  GuardRejectionError,
  MissingParameterError,
  ParameterInjectionError,
  ControllerResolutionError,
} from '@nextrush/controllers';

// Global error handler
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (error) {
    if (error instanceof GuardRejectionError) {
      ctx.status = 403;
      ctx.json({ error: 'Access denied', guard: error.guardName });
      return;
    }

    if (error instanceof MissingParameterError) {
      ctx.status = 400;
      ctx.json({ error: error.message });
      return;
    }

    if (error instanceof ControllerResolutionError) {
      ctx.status = 500;
      ctx.json({ error: 'Internal configuration error' });
      console.error('DI Resolution failed:', error);
      return;
    }

    throw error;
  }
});

Strict Mode

Enable strict mode to fail fast on discovery errors:

await app.plugin(
  controllersPlugin({
    router,
    root: './src',
    strict: true, // Throws on any discovery error
  })
);

In non-strict mode (default), discovery errors are logged as warnings but don't stop the application.


Manual Registration

For testing or when you need explicit control:

import { controllersPlugin, registerController } from '@nextrush/controllers';

// Full plugin with manual controllers
await app.plugin(
  controllersPlugin({
    router,
    controllers: [UserController, PostController],
  })
);

// Or register a single controller directly
registerController(router, UserController);

Manual registration is useful for tests where you want to control which controllers are active.


Plugin State

Access runtime information about registered controllers:

const plugin = controllersPlugin({
  router,
  root: './src',
});

await app.plugin(plugin);

// After installation
console.log(plugin.state);
// {
//   controllers: [...],  // Registered controller metadata
//   routeCount: 12,      // Total routes registered
//   initialized: true,
// }

TypeScript Configuration

Required compiler options for decorators:

Required Configuration

Without experimentalDecorators and emitDecoratorMetadata, decorators silently fail. The DI container cannot resolve constructor parameters.

tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Exports

All public exports from @nextrush/controllers
import {
  // Plugin
  controllersPlugin,
  ControllersPlugin,
  registerController,

  // Discovery
  discoverControllers,
  getControllersFromResults,
  getErrorsFromResults,

  // Registry
  ControllerRegistry,

  // Builder
  buildRoutes,

  // Errors
  ControllerError,
  ControllerResolutionError,
  DiscoveryError,
  GuardRejectionError,
  HttpError,
  MissingParameterError,
  NoRoutesError,
  NotAControllerError,
  ParameterInjectionError,
  RouteRegistrationError,
} from '@nextrush/controllers';

import type {
  BuiltRoute,
  ControllersPluginOptions,
  ControllersPluginState,
  DiscoveryOptions,
  DiscoveryResult,
  RegisteredController,
  ResolvedOptions,
} from '@nextrush/controllers';
Decorator and DI imports
// Decorators
import {
  Controller,
  Get,
  Post,
  Put,
  Patch,
  Delete,
  All,
  Head,
  Options,
  Body,
  Param,
  Query,
  Header,
  Ctx,
  Req,
  Res,
  UseGuard,
  SetHeader,
  Redirect,
  createCustomParamDecorator,
} from '@nextrush/decorators';

import type { GuardFn, GuardContext, CanActivate } from '@nextrush/decorators';

// DI
import { Service, Repository, container, createContainer } from '@nextrush/di';

On this page