@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
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();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 routesPlugin Options
The controllersPlugin() factory accepts these options:
Required
| Property | Type | Description |
|---|---|---|
router | Router | Router instance for route registration |
Auto-Discovery
| Property | Type | Description |
|---|---|---|
root? | string | Root directory to scan for controllers. Enables auto-discovery when provided. |
include | string[]= ['**/*.ts', '**/*.js'] | Glob patterns to include in auto-discovery |
exclude | string[]= ['**/*.test.ts', '**/*.spec.ts', '**/*.test.js', '**/*.spec.js', '**/node_modules/**', '**/dist/**', '**/__tests__/**'] | Glob patterns to exclude from auto-discovery |
strict | boolean= false | Throw on discovery errors instead of logging warnings |
Route Configuration
| Property | Type | Description |
|---|---|---|
prefix | string= '' | 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
| Property | Type | Description |
|---|---|---|
container? | ContainerInterface | Custom DI container. Uses the global container if not provided. |
debug | boolean= false | Enable 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.
{
"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';Related
- Class-Based API Example — Full working example
- DI Package — Dependency injection details
- Decorators Architecture — How decorators work internally