NextRush
Concepts

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:

Loading diagram...
  1. @nextrush/decorators — Store metadata on classes and methods using reflect-metadata
  2. @nextrush/di — Wrap tsyringe with circular dependency detection and actionable errors
  3. @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):

KeyValueTargetStores
CONTROLLERSymbol.for('nextrush:controller')ClassPath, version, middleware, tags
ROUTESSymbol.for('nextrush:routes')ClassHTTP method, path, statusCode, description
PARAMSSymbol.for('nextrush:params')ClassMap<methodName, ParamMetadata[]>
GUARDSSymbol.for('nextrush:guards')Class or methodGuard functions or class constructors

METADATA_KEYS (from @nextrush/di):

KeyValueStores
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:

  1. Store NextRush metadata — type ('service' / 'repository') and scope via Reflect.defineMetadata
  2. 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

ScopeBehaviorUse Case
singleton (default)One instance for entire appServices, repositories
transientNew instance per resolutionRequest-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 → ServiceA

Use 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:

MethodPurpose
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:

Loading diagram...

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: ControllersPlugin class, controllersPlugin factory function
  • Builder: buildRoutes (creates handlers from ControllerDefinition)
  • Discovery: discoverControllers (auto-scan directories for @Controller classes)
  • 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

PackageResponsibility
@nextrush/decoratorsDefine what routes exist (metadata only)
@nextrush/diManage object lifecycle (tsyringe wrapper)
@nextrush/controllersConnect 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:

  1. Missing emitDecoratorMetadata in tsconfig
  2. Using a bundler that doesn't emit metadata (esbuild/tsx instead of nextrush dev)
  3. Missing import 'reflect-metadata' at entry point (not needed if using the nextrush meta-package)
  4. Class not decorated with @Service(), @Repository(), or @Controller()
"Circular dependency detected"

Services depend on each other. Fix with one of:

  1. Refactor to remove the cycle
  2. Use delay() for lazy resolution
  3. Extract shared logic into a third service
"Controller has no routes"

Controller is decorated but has no route methods. Check:

  1. Methods have @Get(), @Post(), etc.
  2. Decorators are imported from @nextrush/decorators
  3. reflect-metadata is imported before decorator files load

reflect-metadata must be imported before any decorator runs. Import it at the top of your entry file.

Next Steps

On this page