Controllers
Auto-discovery and registerControllers() — wiring @Controller classes onto an app.
Build scalable APIs with automatic controller discovery and dependency injection.
registerControllers() scans your source directory for @Controller-decorated classes,
resolves their dependencies, and registers routes on your app's router. No manual wiring
required. For the decorators used to define controllers and routes, see
Decorators. For composing controllers into feature groups,
see Modules.
registerControllers() is a registrar — a plain async function you call and await once at
startup. There is no controllersPlugin(), no ControllersPlugin class, and no app.plugin().
It reads app.router and app.container directly and must complete before listen()/serve()
starts the server.
Why Auto-Discovery?
In production applications with dozens of controllers, manual registration creates maintenance burden. Auto-discovery scales to any size:
// ✅ Auto-discovery scales to any size
await registerControllers(app, {
root: './src',
prefix: '/api/v1',
});Add a controller file matching the *.controller.* convention (e.g. user.controller.ts) anywhere
in ./src — any subdirectory. Decorate the class with @Controller and it is automatically
discovered and registered. No imports, no arrays. Services and guards need no special name; they
load transitively via the controllers that import them.
Installation
If you use the nextrush meta-package, everything is included:
$ pnpm add nextrush
Or install @nextrush/class directly:
$ pnpm add @nextrush/class
The nextrush meta-package auto-imports reflect-metadata for you. If using @nextrush/class
directly, install reflect-metadata separately and import it first in your entry point —
see Dependency Injection for the full setup.
Quick Start
import { createApp, listen } from 'nextrush';
import { registerControllers } from 'nextrush/class';
async function main() {
const app = createApp();
// Auto-discover all @Controller classes in ./src
// Any file name, any subdirectory — no naming convention required
await registerControllers(app, {
root: './src',
prefix: '/api',
});
await listen(app, 8080);
}
main();import { Controller, Get, Post, Body, Service } from 'nextrush/class';
@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 routesregisterControllers(app, options)
Signature:
function registerControllers(app: Application, options?: ControllersOptions): Promise<void>;Reads app.router (required — throws if absent) and app.container (falls back to options.container, then the global container). Always await it before starting the server.
Parameters
| Property | Type | Description |
|---|---|---|
app | Application | The app instance. Must have a router — use `createApp()` from `nextrush`, or pass `{ router }` to `@nextrush/core`'s `createApp()`. |
options? | ControllersOptions | Discovery and registration options |
Auto-Discovery
| Property | Type | Description |
|---|---|---|
root? | string | Root directory to scan for controllers. Enables auto-discovery when provided. |
include | string[]= ['**/*.controller.ts', '**/*.controller.js'] | Glob patterns to include. Defaults to the *.controller.* convention; pass ["**/*.ts", "**/*.js"] to scan every source file. Matched files are dynamically imported (runs their top-level code). |
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 |
Advanced
| Property | Type | Description |
|---|---|---|
container? | Container | Custom DI container. Falls back to `app.container`, then the global container. |
debug | boolean= false | Enable debug logging for discovery and registration |
controllers? | Function[] | Explicit controller list — a first-class alternative to auto-discovery (no filesystem scan). Merged with any `root`-discovered controllers. |
Returns: Promise<void> — resolves once every discovered controller's routes are registered on app.router.
Throws: if app.router is missing, or (in strict: true mode) if a discovery error occurs.
Discovery Patterns
Default Patterns
By default, registerControllers imports files matching the *.controller.* convention, excluding tests and build artifacts:
await registerControllers(app, {
root: './src',
// Default patterns:
// include: ['**/*.controller.ts', '**/*.controller.js']
// exclude: ['**/*.test.ts', '**/*.spec.ts', '**/*.test.js', '**/*.spec.js',
// '**/node_modules/**', '**/dist/**', '**/__tests__/**']
});Discovery imports run module code
Discovery dynamically import()s every matched module, executing its top-level code (DI
registration, singleton construction). By default, only *.controller.* files under root are
imported — services, guards, and repositories need no special name, they load transitively via
the controllers that import them. Narrow include further if a matched module has side-effects
you don't want at startup, or pass the scan-all escape hatch (include: ['**/*.ts', '**/*.js'])
to scan every file instead.
Custom Patterns
Narrow the scan to specific directories or naming conventions:
await registerControllers(app, {
root: './src',
include: ['controllers/**/*.ts'],
});await registerControllers(app, {
root: './src',
include: ['**/*.controller.ts', 'modules/**/*.controller.ts'],
});await registerControllers(app, {
root: './src/features',
include: ['**/controller.ts', '**/routes.ts'],
});Response & Status Codes
A value returned from a handler is serialized as JSON with HTTP 200. Returning an object with a
status field does not change the HTTP status — the response is still 200, with status sitting
in the body.
@Get('/:id')
findOne(@Param('id') id: string) {
return { id }; // 200 OK, Content-Type: application/json
}To send a different status code, pick one of three approaches:
| Approach | Use when |
|---|---|
@HttpCode(201) | The status is fixed for the route |
Inject @Ctx() and set ctx.status | The status depends on runtime logic |
throw an HttpError subclass | Signalling an error (e.g. NotFoundError → 404) |
The thrown-error path is the idiomatic way to return a 404 from a controller — throw a typed
HttpError and let the error handler map it to the right status and body.
import { NotFoundError } from 'nextrush';
@Controller('/users')
export class UserController {
@Get('/:id')
findById(@Param('id', { transform: Number }) id: number) {
const user = this.userService.findById(id);
if (!user) {
throw new NotFoundError('User not found'); // → 404 Not Found
}
return user; // → 200 OK
}
}Controller Lifecycle: Singletons
Controllers are resolved from the DI container as singletons by default. One instance is
created lazily on the first request to a route, then reused for every request afterward and
shared across all concurrent requests. Keep controllers stateless — put per-request data in
ctx.state (via @Ctx()), never on this.
// ❌ Wrong — `this.currentUser` is shared by every request
@Controller('/users')
export class UserController {
private currentUser?: User;
@Get('/me')
me(@Ctx() ctx: Context) {
this.currentUser = ctx.state.user as User; // leaks into the next request
return this.currentUser;
}
}
// ✅ Correct — per-request state lives in ctx.state
@Controller('/users')
export class UserController {
@Get('/me')
me(@Ctx() ctx: Context) {
return ctx.state.user;
}
}Constructor-injected services and repositories are safe to hold on this — they are themselves
singletons, transient, or request-scoped. A controller with a request-scoped dependency
anywhere in its graph is resolved fresh per request automatically; see
Dependency Injection & Scopes for how scope bubbling
works through registerControllers.
Dependency Injection
Controllers are constructor-injected from the DI container. See
Dependency Injection for @Service, @Repository, and scopes.
Custom Container
import { createContainer, registerControllers } from 'nextrush/class';
const container = createContainer();
// Register configuration
container.register('CONFIG', {
useValue: { apiKey: process.env.API_KEY },
});
await registerControllers(app, {
root: './src',
container,
});Or pass the container directly to createApp() so it flows to every registrar and extension automatically:
const app = createApp({ container });
await registerControllers(app, { root: './src' }); // uses app.containerError and Failure Behavior
import {
GuardRejectionError,
MissingParameterError,
ParameterInjectionError,
ControllerResolutionError,
} from 'nextrush/class';
// 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;
}
});Error Classes
| Property | Type | Description |
|---|---|---|
ControllerError | Error | Base class for controller/registration errors |
ControllerResolutionError | ControllerError | DI failed to resolve a controller or its dependencies |
DiscoveryError | ControllerError | Filesystem discovery of controller files failed |
GuardRejectionError | ControllerError | A guard returned false (see Decorators — Guards) |
MissingParameterError | ControllerError | A required parameter (e.g. createCustomParamDecorator({ required: true })) was undefined |
NoRoutesError | ControllerError | No routes were discovered/registered |
NotAControllerError | ControllerError | A class passed to `controllers` has no @Controller metadata |
NotAModuleError | ControllerError | A class passed to a Module's `imports` has no @Module metadata |
ParameterInjectionError | ControllerError | A parameter transform threw or returned an invalid value |
RouteRegistrationError | ControllerError | The router rejected a route registration (e.g. duplicate) |
Strict Mode
Enable strict mode to fail fast on discovery errors:
await registerControllers(app, {
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.
Explicit Registration
Pass controllers directly to register a known list of classes without scanning the
filesystem. This is a first-class, fully supported alternative to auto-discovery — reach
for it when explicit wiring reads better than convention: greppable registration,
deterministic order, or no filesystem scan (tests, bundled or serverless builds where dynamic
import() of a source tree isn't available):
import { registerControllers } from 'nextrush/class';
// Explicit controller list — no filesystem scanning
await registerControllers(app, {
controllers: [UserController, PostController],
});root (auto-discovery) and controllers (explicit) can be combined — discovered controllers
and those passed in controllers are merged.
Discovery Utilities
Lower-level building blocks used internally by registerControllers, useful for custom tooling:
import {
discoverControllers,
getControllersFromResults,
getErrorsFromResults,
FilesystemSource,
MemorySource,
ControllerRegistry,
buildRoutes,
getClassDiagnostics,
} from 'nextrush/class';discoverControllers(source, options)— run discovery against aDiscoverySource.FilesystemSource— the default source; scans a directory withinclude/excludeglobs.MemorySource— an in-memory source for tests, taking an explicit class list.ControllerRegistry— tracks discovered/registered controllers.buildRoutes(controllers, options)— build router-ready route definitions from controller metadata without registering them.getClassDiagnostics(app)— routes, providers, circular dependencies, and timings for the whole registration graph.
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
}
}TypeScript Exports
Complete import reference
import {
// Registrar
registerControllers,
// Discovery
discoverControllers,
getControllersFromResults,
getErrorsFromResults,
FilesystemSource,
MemorySource,
// Registry
ControllerRegistry,
// Builder
buildRoutes,
// Diagnostics
getClassDiagnostics,
// Errors
ControllerError,
ControllerResolutionError,
DiscoveryError,
GuardRejectionError,
HttpError,
MissingParameterError,
NoRoutesError,
NotAControllerError,
NotAModuleError,
ParameterInjectionError,
RouteRegistrationError,
} from 'nextrush/class';
import type {
BuiltRoute,
ControllersOptions,
DiscoveryOptions,
DiscoveryResult,
RegisteredController,
ResolvedOptions,
DiscoverySource,
ApplicationGraph,
CircularDependency,
DiagnosticsReport,
DuplicateRoute,
ProviderEntry,
RouteEntry,
TimingEntry,
} from 'nextrush/class';Related
- Class-Based Controllers Guide — Full working example
- Decorators — Controller, route, param, guard, interceptor, filter decorators
- Modules — Composing controllers into feature groups
- Dependency Injection & Scopes — How DI works internally