DI & Decorators
How NextRush implements dependency injection and decorator-based metadata for class-based controllers
NextRush splits class-based controllers into three independent packages: decorator metadata, a DI container, and a plugin that connects them. This page explains the internal architecture of each layer and how they compose.
The Mental Model
Three packages work together but remain independently usable:
@nextrush/decorators— Store metadata on classes and methods usingreflect-metadata@nextrush/di— Wrap tsyringe with circular dependency detection and actionable errors@nextrush/controllers— Read metadata, resolve from DI, build route handlers
How Decorators Work
The Metadata Problem
TypeScript decorators run at class definition time. They can't communicate with later code on their own.
The solution: reflect-metadata stores structured data on class constructors using Symbol keys.
// When you write this:
@Controller('/users')
class UserController {
@Get('/:id')
findById() {}
}
// Decorators call reflect-metadata at load time:
Reflect.defineMetadata(Symbol.for('nextrush:controller'), { path: '/users' }, UserController);
Reflect.defineMetadata(
Symbol.for('nextrush:routes'),
[{ method: 'GET', path: '/:id', methodName: 'findById', propertyKey: 'findById' }],
UserController // stored on the constructor, not the prototype
);Metadata Keys
Both packages define their own key constants:
DECORATOR_METADATA_KEYS (from @nextrush/decorators):
| Key | Value | Target | Stores |
|---|---|---|---|
CONTROLLER | Symbol.for('nextrush:controller') | Class | Path, version, middleware, tags |
ROUTES | Symbol.for('nextrush:routes') | Class | HTTP method, path, statusCode, description |
PARAMS | Symbol.for('nextrush:params') | Class | Map<methodName, ParamMetadata[]> |
GUARDS | Symbol.for('nextrush:guards') | Class or method | Guard functions or class constructors |
METADATA_KEYS (from @nextrush/di):
| Key | Value | Stores |
|---|---|---|
SERVICE_TYPE | 'di:type' | 'service' or 'repository' |
SERVICE_SCOPE | 'di:scope' | 'singleton' or 'transient' |
PARAM_TYPES | 'design:paramtypes' | Constructor parameter types (emitted by compiler) |
Reading Metadata
The @nextrush/decorators package exports reader functions. The controllers plugin uses getControllerDefinition:
import { getControllerDefinition } from '@nextrush/decorators';
const definition = getControllerDefinition(UserController);
// Returns ControllerDefinition:
// {
// target: UserController,
// controller: { path: '/users', version: undefined, middleware: undefined, tags: undefined },
// routes: [{ method: 'GET', path: '/:id', methodName: 'findById', propertyKey: 'findById' }],
// params: Map { 'findById' => [{ source: 'param', index: 0, name: 'id', required: true }] }
// }Guards are read separately via getAllGuards(target, methodName), which returns class-level guards first, then method-level guards.
How DI Works
Container Architecture
NextRush's DI container wraps tsyringe. The wrapper adds:
- Circular dependency detection via a Set-based resolution stack (O(1) lookup), including tsyringe-internal chain detection
- Enhanced error messages with resolution chain context and actionable fix suggestions (
@Service(),@Repository(),@Config()hints) - Child containers for test isolation
- Optional dependencies via
@Optional()decorator
tsyringe handles the actual singleton caching, constructor parameter resolution, and instance creation internally. The NextRush wrapper intercepts resolve() calls, tracks the resolution stack, and rethrows errors with actionable context.
The Decorator Metadata Problem
DI containers need constructor parameter types at runtime:
@Service()
class UserService {
constructor(private db: DatabaseService) {}
}How does the container know db is a DatabaseService? TypeScript types are erased at runtime.
The answer: emitDecoratorMetadata. When enabled, the compiler emits:
Reflect.defineMetadata('design:paramtypes', [DatabaseService], UserService);Critical Requirement
Most bundlers (esbuild, tsup, tsx) don't emit design:paramtypes. Use nextrush dev and
nextrush build which use SWC with proper metadata emission.
What @Service() and @Repository() Do
Both decorators perform two operations:
- Store NextRush metadata — type (
'service'/'repository') and scope viaReflect.defineMetadata - Apply tsyringe decorator —
@singleton()for singleton scope,@injectable()for transient
@Repository() is semantically identical to @Service(). It exists for code organization, not behavior.
@Controller() also registers the class as a singleton via tsyringe's @singleton() — you do NOT need @Service() alongside @Controller().
Scopes
| Scope | Behavior | Use Case |
|---|---|---|
singleton (default) | One instance for entire app | Services, repositories |
transient | New instance per resolution | Request-scoped data |
@Service() // singleton by default
class DatabaseService {}
@Service({ scope: 'transient' })
class RequestLogger {
readonly timestamp = Date.now(); // Different each time
}Circular Dependencies
The container wrapper tracks a resolution stack. If a token appears twice during a single resolution chain, it throws CircularDependencyError:
@Service()
class ServiceA {
constructor(private b: ServiceB) {}
}
@Service()
class ServiceB {
constructor(private a: ServiceA) {}
}
// CircularDependencyError: ServiceA → ServiceB → ServiceAUse delay() to break cycles with lazy resolution:
import { delay, inject } from '@nextrush/di';
@Service()
class ServiceA {
constructor(@inject(delay(() => ServiceB)) private b: ServiceB) {}
}Container Interface
The ContainerInterface exposes these methods:
| Method | Purpose |
|---|---|
register(token, provider) | Register with useClass, useValue, or useFactory |
resolve(token) | Resolve a dependency (with circular detection) |
resolveAll(token) | Resolve all registrations under a token |
isRegistered(token) | Check if a token has a registration |
clearInstances() | Clear cached singletons (useful for tests) |
reset() | Reset the container completely |
createChild() | Create an isolated child container |
createContainer() creates a new isolated container by spawning a child of the global tsyringe container and resetting it.
How the Controllers Plugin Works
The controllersPlugin connects decorators, DI, and the router. It requires a router instance:
Route Handler Building
For each controller method, the plugin builds an async handler. Here is what the generated handler does at request time:
Generated handler internals (expanded from builder.ts)
// Your code:
@Controller('/users')
class UserController {
@Get('/:id')
findById(@Param('id') id: string) {
return { id };
}
}
// The built handler (simplified from builder.ts):
async function handler(ctx: Context) {
// 1. Execute guards (class guards first, then method guards)
for (const guard of guards) {
let result: boolean;
if (isGuardClass(guard)) {
// Class-based guard — resolved from DI container
const instance = container.resolve(guard);
result = await instance.canActivate(guardContext);
} else {
// Function-based guard
result = await guard(guardContext);
}
if (!result) {
throw new GuardRejectionError(guardName, 'Access denied');
}
}
// 2. Resolve controller from DI
const controller = container.resolve(UserController);
// 3. Extract parameters from context
const args = [];
for (const param of paramMetadata) {
let value;
switch (param.source) {
case 'param':
value = param.name ? ctx.params[param.name] : ctx.params;
break;
case 'query':
value = param.name ? ctx.query[param.name] : ctx.query;
break;
case 'body':
value = param.name ? ctx.body[param.name] : ctx.body;
break;
case 'header':
value = param.name ? ctx.get(param.name) : ctx.headers;
break;
case 'ctx':
value = ctx;
break;
case 'req':
value = ctx.raw.req;
break;
case 'res':
value = ctx.raw.res;
break;
case 'custom':
// Custom parameter decorators (created via createCustomParamDecorator)
value = await param.customExtractor(ctx);
break;
}
if (param.transform) value = await param.transform(value);
args[param.index] = value;
}
// 4. Call controller method
const result = await controller.findById(...args);
// 5. Apply @SetHeader decorators (precomputed at build time)
for (const { name, value } of responseHeaders) {
ctx.set(name, value);
}
// 6. Handle @Redirect or auto-serialize response
if (redirectMetadata) {
const url = typeof result === 'string' ? result : redirectMetadata.url;
const code = result?.statusCode ?? redirectMetadata.statusCode;
ctx.status = code;
ctx.set('Location', url);
ctx.send('');
} else if (result !== undefined) {
typeof result === 'object' ? ctx.json(result) : ctx.send(String(result));
}
}Guard rejection throws GuardRejectionError (a 403 ForbiddenError subclass), not a manual status assignment. This integrates with NextRush's error handling middleware.
Guards receive a GuardContext — a lightweight read-only subset of Context with method,
path, params, query, headers, body, state, and a get() method. Guards cannot send
responses directly.
Package Responsibilities
@nextrush/decorators
Pure metadata storage and reading — no runtime behavior:
- Class:
Controller(with auto-path derivation from class name) - Routes:
Get,Post,Put,Delete,Patch,Head,Options,All - Response:
SetHeader,Redirect - Params:
Body,Param,Query,Header,Ctx,Req,Res,createCustomParamDecorator - Guards:
UseGuard,getAllGuards,getClassGuards,getMethodGuards - Readers:
getControllerDefinition,getControllerMetadata,getRouteMetadata,getParamMetadata,buildFullPath,isController,getResponseHeaders,getRedirectMetadata
@All creates route entries for GET, POST, PUT, DELETE, and PATCH (5 methods).
@nextrush/di
Container wrapper and registration decorators:
- Container:
container(global instance),createContainer(isolated child) - Decorators:
Service,Repository,AutoInjectable,Optional - Injection:
inject(explicit token),delay(break circular deps) - Introspection:
hasServiceMetadata,getServiceType,getServiceScope,isParameterOptional,getOptionalParams - Errors:
CircularDependencyError,DependencyResolutionError,MissingDependencyError,InvalidProviderError,TypeInferenceError - Constants:
METADATA_KEYS
@nextrush/controllers
Orchestration — reads metadata, resolves from DI, registers on the router:
- Plugin:
ControllersPluginclass,controllersPluginfactory function - Builder:
buildRoutes(creates handlers fromControllerDefinition) - Discovery:
discoverControllers(auto-scan directories for@Controllerclasses) - Registry:
ControllerRegistry(manages registered controllers) - Errors:
GuardRejectionError,MissingParameterError,ParameterInjectionError,ControllerResolutionError
This package does NOT re-export decorators or DI — import those from their own packages.
Why This Architecture?
Separation of Concerns
| Package | Responsibility |
|---|---|
@nextrush/decorators | Define what routes exist (metadata only) |
@nextrush/di | Manage object lifecycle (tsyringe wrapper) |
@nextrush/controllers | Connect metadata + DI + router at startup |
You can use decorators without DI, or DI without controllers. The controllers plugin is the only package that depends on both.
Testability
Services and controllers are plain classes. Test without the framework:
const mockRepo = { findById: () => ({ id: '1' }) };
const service = new UserService(mockRepo as UserRepository);Use createContainer() for test isolation — it creates a fresh child container.
Tree Shaking
If you use the functional style, none of these packages are bundled:
import { createApp } from '@nextrush/core';
import { createRouter } from '@nextrush/router';Common Issues
These are the most frequent problems when using DI and decorators.
"TypeInfo not known for X"
The DI container can't find constructor types. Causes:
- Missing
emitDecoratorMetadatain tsconfig - Using a bundler that doesn't emit metadata (esbuild/tsx instead of
nextrush dev) - Missing
import 'reflect-metadata'at entry point (not needed if using thenextrushmeta-package) - Class not decorated with
@Service(),@Repository(), or@Controller()
"Circular dependency detected"
Services depend on each other. Fix with one of:
- Refactor to remove the cycle
- Use
delay()for lazy resolution - Extract shared logic into a third service
"Controller has no routes"
Controller is decorated but has no route methods. Check:
- Methods have
@Get(),@Post(), etc. - Decorators are imported from
@nextrush/decorators reflect-metadatais imported before decorator files load
reflect-metadata must be imported before any decorator runs. Import it at the top of your entry
file.