-
Notifications
You must be signed in to change notification settings - Fork 0
Controllers and Decorators
Class-based controllers turn the functional API's per-route wiring into declarations. Instead of
extracting request values, checking dependencies, guarding auth, and mapping errors by hand in
every handler, you decorate a class once and registerControllers builds the whole graph.
The class runtime is a registrar on top of the functional core — it reads app.router and
app.container and mounts routes. It is not a framework rewrite.
The functional API reads well for a handful of routes, but each handler repeats the same
plumbing: pulling values off ctx, constructing dependencies, re-running auth, re-mapping errors.
| Aspect | Functional | Class-based |
|---|---|---|
| Lines of code | ~80 | ~200 |
| Setup | Minimal | More boilerplate |
| DI support | Manual | Automatic |
| Testing | Mock functions | Inject mocks |
| Type safety | Good | Excellent |
| Scalability | Medium | High |
Choose class-based when you have enough routes, services, and collaborators that automatic DI and declarative structure pay for the extra boilerplate.
Decorators need metadata emitted at compile time. Your tsconfig.json must enable:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}The nextrush meta-package auto-imports reflect-metadata, so no manual import is needed when
you use nextrush/class. Build class apps with nextrush dev / nextrush build — fast bundlers
(esbuild, tsx, swc) skip decorator-metadata emission, and DI resolution then fails with
undefined constructor arguments.
import { createApp, listen } from 'nextrush';
import { Controller, Get, Post, Param, Body, Service, Module, registerModule } from 'nextrush/class';
@Service() // singleton, one instance shared
class UserService {
private users = [{ id: '1', name: 'Ada' }];
findAll() { return this.users; }
findOne(id: string) { return this.users.find((u) => u.id === id) ?? null; }
create(data: { name: string }) {
const user = { id: String(this.users.length + 1), ...data };
this.users.push(user);
return user;
}
}
@Controller('/users')
class UserController {
constructor(private readonly users: UserService) {} // resolved from DI
@Get()
findAll() { return this.users.findAll(); }
@Get('/:id')
findOne(@Param('id') id: string) { return this.users.findOne(id); }
@Post()
create(@Body() data: { name: string }) { return this.users.create(data); }
}
// Module-first: the feature owns its controller and provider...
@Module({ controllers: [UserController], providers: [UserService] })
class UsersModule {}
@Module({ imports: [UsersModule] })
class AppModule {}
const app = createApp();
await registerModule(app, AppModule);
await listen(app, 8080);Nobody writes new or reads ctx by hand. @Controller marks the class an HTTP controller
(and DI-resolvable), the constructor declares its dependency, @Param('id') extracts the route
param, and registerModule walks the module graph, registers the module's providers, and builds
all routes — eagerly resolving every controller once at boot so a broken dependency fails here,
not as a first-request 500.
| Decorator | Runs on | What it does |
|---|---|---|
@Controller(path | options) |
class | Marks an HTTP controller; adds a path prefix (+ optional version, middleware, tags) |
@Get @Post @Put @Patch @Delete @Head @Options @All
|
method | Bind a method to an HTTP verb + path |
@Body |
parameter | Request body (whole body, or @Body('name') for a field) |
@Param |
parameter | Route parameter (one, or all with @Param()) |
@Query |
parameter | Query string (one key, or all) |
@Header |
parameter | A header (or all) |
@Ctx |
parameter | The full Context
|
@Req / @Res
|
parameter | Raw request / response objects |
@SetHeader(name, value) |
method | Set a response header (stackable) |
@Redirect(url, status?) |
method | Redirect via the Location header (default 302) |
@HttpCode(status) |
method | Fixed success status for the route |
@UseGuard |
class / method | Attach a GuardFn or a DI-resolved CanActivate class |
@UseInterceptor |
class / method | Wrap the handler (around advice) |
@Catch(...) + @UseFilter(...)
|
class | Map thrown errors to responses for a controller/route |
createCustomParamDecorator |
factory | Build a reusable custom parameter extractor |
Example response decorators:
import { Controller, Get, SetHeader, HttpCode, Post } from 'nextrush/class';
@Controller('/api')
class ApiController {
@SetHeader('Cache-Control', 'no-store')
@Get('/status')
getData() { return { result: 'ok' }; }
}
@Controller('/users')
class UserController {
@HttpCode(201)
@Post()
create() { return { created: true }; }
}@Controller accepts either a path string or an options object:
@Controller({ path: '/users', version: 'v1', tags: ['users'] })
class UserController {}| Option | Meaning |
|---|---|
path |
Base path prefix. Omitted → derived from the class name (UserController → /user). |
version |
API version prefix — 'v1' mounts every route under /v1/users. |
middleware |
MiddlewareRef[] — middleware applied to every route on this controller. |
tags |
string[] — documentation grouping tags. |
Every route decorator accepts a path string, an options object, or both:
@Get('/search', { description: 'Search users', deprecated: false })
@Post('/bulk', { statusCode: 201 })
@Get('/:id', { middleware: [CacheMiddleware] })| Option | Meaning |
|---|---|
path |
Route path (joined to the controller prefix). Default /. |
statusCode |
Success status for this route (overridden by @HttpCode when both are set). |
description |
Route description (docs/OpenAPI metadata). |
deprecated |
Marks the route deprecated (docs/OpenAPI metadata). |
middleware |
MiddlewareRef[] — middleware applied only to this route, after controller middleware. |
@All(path) registers a single any-method route, matched by every standard HTTP verb.
@Param, @Query, @Body, and @Header accept a second options argument for transforms,
defaults, and required checks. Which options are available depends on the source:
| Export | Options | Default required
|
Other optionals |
|---|---|---|---|
@Body |
required, transform
|
true |
— |
@Param |
required, transform, defaultValue
|
true |
— |
@Query |
required, transform, defaultValue
|
false |
— |
@Header |
required, defaultValue
|
false |
no transform
|
@Get('/:id')
findOne(@Param('id', { transform: Number }) id: number) {}
@Get()
findAll(
@Query('page', { defaultValue: 1, transform: Number }) page: number,
@Query('q', { transform: (s) => s.toLowerCase() }) q?: string
) {}
@Post()
create(@Body({ transform: validateCreateUser }) data: CreateUserDto) {}-
transform: (value) => value— applied to the extracted value before injection; sync or async (Number,parseInt, a validator returning the sanitized value, …). -
defaultValue— used when the source is absent (only where the table lists it). -
required— force an optional source to reject a missing value withMissingParameterError(400) instead of injectingundefined. Body/param default to required; query/header default to optional.
@Body() injects the whole parsed body; @Body('email') injects one field. @Param(),
@Query(), and @Header() with no argument inject the whole source object.
createCustomParamDecorator(extractor, options?) builds a reusable parameter decorator:
import { createCustomParamDecorator } from 'nextrush/class';
const UserAgent = createCustomParamDecorator(
(ctx) => ctx.get('user-agent'),
{ required: true }
);
@Controller('/api')
class ApiController {
@Get()
handle(@UserAgent ua: string) {}
}The extractor receives the Context and returns the value (sync or Promise). Options mirror
@Param's (single extractor first, then { transform?, required? }): transforms apply to the
extracted value, and required: true throws MissingParameterError when the router is empty.
A module-first app wires a whole graph in one call with registerModule(app, AppModule) — see
Modules. registerControllers is the underlying registrar both paths share; you await
it once before starting the server:
import { createApp, listen } from 'nextrush';
import { registerControllers } from 'nextrush/class';
const app = createApp();
// Auto-discovery: scans ./src for *.controller.* files
await registerControllers(app, { root: './src', prefix: '/api' });
// OR an explicit list — no filesystem scan (tests, serverless):
// await registerControllers(app, { controllers: [UserController] });
await listen(app, 8080);| Option | Type | Default | Meaning |
|---|---|---|---|
controllers |
Function[] |
[] |
Explicit classes; merged with root discovery |
root |
string |
— | Directory to scan; enables auto-discovery |
include |
string[] |
*.controller.{ts,js} |
Glob patterns for discovery |
exclude |
string[] |
test / node_modules / dist
|
Patterns excluded |
prefix |
string |
'' |
Prefix prepended to every route |
container |
Container |
app.container → global |
Container to resolve from |
middleware |
Middleware[] |
[] |
Global middleware for all routes |
validate |
boolean |
true |
Eagerly resolve every controller at boot |
strict |
boolean |
false |
Throw on discovery errors instead of logging |
debug |
boolean |
false |
Log discovery/registration to stderr |
Discovery dynamically
import()s every matched file, running its top-level code. Prefer a narrow scope or an explicitcontrollerslist when a source file has side effects.
Controllers resolve as singletons and are shared across requests — keep them stateless and put
per-request data in ctx.state via @Ctx(), never on this. A returned value serializes as JSON
with HTTP 200. To change the status: @HttpCode(201) for a fixed value, inject @Ctx() and set
ctx.status for runtime logic, or throw an HttpError subclass to signal an error:
import { NotFoundError } from 'nextrush';
@Controller('/users')
export class UserController {
@Get('/:id')
findById(@Param('id') id: string) {
const user = this.userService.findById(id);
if (!user) throw new NotFoundError('User not found'); // → 404
return user;
}
}@UseGuard, @UseInterceptor, and @Catch/@UseFilter each have their own page, with the
full contract, ordering, and common pitfalls:
- Guards — yes/no access checks that run before the handler
- Interceptors — wrap the handler to shape its input/output
- Exception Filters — map a thrown error to a response for a route
- Dependency Injection — how the container assembles and scopes the graph
- Request Scope — per-request services and scope bubbling
- Modules — group a feature's controllers and providers behind one declaration
- Discovery — auto-discovering controllers instead of hand-listing them
-
Lifecycle —
onInit/onShutdownhooks on services in the graph - Diagnostics — introspection report for your registered routes and providers
- Extensions — long-lived app-scoped services
- Streaming — respond in chunks (text / SSE / NDJSON)
- Class guide: https://0xtanzim.github.io/nextRush/docs/guides/api-development
- Decorators reference: https://0xtanzim.github.io/nextRush/docs/reference/class/decorators
- Controllers reference: https://0xtanzim.github.io/nextRush/docs/reference/class/controllers
NextRush · MIT License · Docs · Issues