ConceptsClass Runtime

Dependency Injection

How NextRush resolves a class's constructor dependencies for you, and what singleton, transient, and request scope each guarantee about instance lifetime.

A service almost never works alone: a UserService needs a repository, the repository needs a database client, and the client needs configuration. Somewhere, something has to build that chain in the right order. Do it by hand and every class ends up knowing how to construct its dependencies — and their dependencies, and theirs.

What you'll learn

  • Understand why wiring dependencies by hand couples classes together and makes tests awkward
  • Understand how the container resolves a class's dependencies from its constructor types
  • Recognize what singleton, transient, and request scope each guarantee about an instance's lifetime
  • Choose the right scope for a class — and when a class needs the container at all

The problem

The direct way to give a class its dependencies is to construct them inline. It reads fine for one class, and then it spreads:

// Manual wiring — every consumer hard-codes how to build what it needs.
class UserRepository {
  findAll() {
    return [{ id: 1, name: 'Alice' }];
  }
}

class UserService {
  private repo = new UserRepository(); // constructs its own dependency

  getUsers() {
    return this.repo.findAll();
  }
}

class UserController {
  private service = new UserService(); // and again, one level up
}

Each new welds a class to a concrete constructor. To swap UserRepository for a mock in a test, you have to reach inside UserService and change how it builds itself. A dependency that should be shared — one database pool — gets constructed once per consumer instead. And the lifetime of every instance is whatever new happens to give you, with no way to say "share this one" or "make a fresh one each time."

Why this matters

Wiring is not one-time setup — it is paid on every class you add, and every constructor you change ripples outward to everything that constructs it. Multiply that across a growing service graph and a growing team, and the hard-coded new calls become the reason a small change touches ten files and a test needs a real database to run. The coupling never shows up as a single dramatic bug; it shows up as steady friction on every change.

The solution

NextRush wraps tsyringe in a small container (@nextrush/di, re-exported from nextrush/class). You declare what a class needs as constructor parameters and mark the class with @Service(). At resolution time the container reads each parameter's type, resolves it recursively, and constructs your class with the results. You describe what a class depends on; the container works out how to build it — and a scope decides how long each resolved instance lives.

Core idea

Think of the container as a lazy, memoizing factory. Decorating a class with @Service() constructs nothing — it only records metadata. Construction happens the first time something calls resolve(), and what the container does at that moment is governed entirely by the class's scope: build-and-cache, build-every-time, or build-once-per-request.

Mental model

Loading diagram...

Notice that nothing is built at decoration time — the only branch that matters happens at resolve(), where the declared scope decides whether the instance is cached and for how long. The three scopes differ in exactly one thing: an instance's lifetime.

Quick example

The smallest useful graph is a controller that needs a service that needs a repository. Mark each class, declare the dependency as a constructor parameter, and register the controller — the container assembles the whole chain:

app.ts
import { createApp, listen } from 'nextrush';
import { Service, Controller, Get, registerControllers } from 'nextrush/class';

@Service() // singleton by default
class UserRepository {
  findAll() {
    return [{ id: 1, name: 'Alice' }];
  }
}

@Service()
class UserService {
  constructor(private repo: UserRepository) {} // declared, not constructed

  getUsers() {
    return this.repo.findAll();
  }
}

@Controller('/users')
class UserController {
  constructor(private users: UserService) {} // injected by the container

  @Get()
  list() {
    return this.users.getUsers();
  }
}

const app = createApp();
await registerControllers(app, { controllers: [UserController] });
listen(app, 8080);

Not one class writes new. registerControllers() resolves UserController from the container, which reads its constructor's UserService type and resolves that first, which in turn resolves UserRepository — the whole chain is built from constructor parameter types. registerModule() wires the same graph the same way, and either is the path a class-based app uses instead of calling container.resolve() by hand.

How it works

Example — a class's declared scope is not the whole story. A request-scoped leaf changes how everything above it resolves:

bubbling.ts
import { Service, Controller, Get } from 'nextrush/class';

@Service({ scope: 'request' })
class RequestId {
  readonly id = crypto.randomUUID();
}

@Service() // declared singleton — but see below
class AuditLogger {
  constructor(private requestId: RequestId) {}

  log(message: string) {
    return `[${this.requestId.id}] ${message}`;
  }
}

@Controller('/orders')
class OrderController {
  constructor(private audit: AuditLogger) {}

  @Get()
  list() {
    return this.audit.log('listing orders');
  }
}

ObservationAuditLogger declares no scope, so on its own it would be a singleton. Yet each request sees a fresh RequestId, and OrderController is resolved fresh per request too — even though neither class is marked request.

Explanation — NextRush computes each class's effective scope by walking its constructor dependency graph. A class is effectively request-scoped if it declares scope: 'request' or anything it transitively depends on does. So AuditLogger bubbles to request-effective through RequestId, and OrderController bubbles through AuditLogger. For an effectively request-scoped controller, the runtime resolves it from a fresh container.createChild() on each request: request-scoped dependencies are built once per child and shared within that request, while true singletons still resolve from the parent and stay shared. Without this bubbling, a singleton AuditLogger would capture one RequestId forever and stamp every request with the same stale value. The deeper mechanics live in the DI internals — a concept teaches the model, not the machinery.

DI needs decorator metadata at compile time

The container reads constructor parameter types from design:paramtypes, which TypeScript emits only with emitDecoratorMetadata enabled. Many fast bundlers (esbuild, tsx, plain swc) skip that emission, and resolution then fails with undefined constructor arguments. Build class-based apps with nextrush dev / nextrush build, which compile with the metadata DI depends on.

Typical use cases

Reach for the container when a class has collaborators that benefit from being shared or swapped:

  • A repository or data client shared across services — one instance, resolved wherever it's needed, and swappable for a fake in tests.
  • A service that depends on other services — declare them in the constructor and let the container assemble the graph.
  • Per-request state — a request id, a per-request cache, or a user context that must not leak between requests: mark it scope: 'request'.

A class with no dependencies and no shared state gains nothing from the container — see When to avoid it below.

Configuration

The one setting that changes a class's behavior is its scope, passed to @Service:

scopes.ts
import { Service } from 'nextrush/class';

@Service() // singleton — the implicit default
class ConfigService {}

@Service({ scope: 'transient' }) // a new instance on every resolve
class Formatter {}

@Service({ scope: 'request' }) // one instance per HTTP request
class RequestContext {
  readonly id = crypto.randomUUID();
}

Changing the scope changes only when an instance is constructed and how long it lives — singleton is built at most once and shared app-wide, transient is rebuilt on every resolve() and never cached, and request is built once per request and shared within it. @Repository() behaves identically to @Service(); it exists so a data-access class can be named by what it is. The full option and method surface — Container, Service, Repository, and the error types — lives in the @nextrush/di reference; this page covers only what the scope means.

Performance

  • Complexity — resolving a class walks its constructor graph once per resolve(). A singleton, once built, is a cached lookup on every subsequent resolve, so a steady-state singleton graph costs almost nothing after boot.
  • Memory — singletons are held for the application's lifetime; request-scoped instances live only as long as their request's child container, then become eligible for collection; transient instances are never retained by the container at all.
  • Scaling — a purely singleton controller graph keeps a memoized fast path with no added per-request work. Introducing a single request-scoped dependency anywhere in that graph opts the whole controller resolution into a per-request createChild() — correct, but no longer free. Measure with apps/benchmark before tuning.

Security

DI decides how long an instance and its state live, which makes scope a correctness-and-isolation boundary, not only a performance knob.

  • Cross-request state leakage. Threat: per-user or per-request data stored on a singleton is shared by every concurrent request. Why: a singleton is one instance for the whole app, so a field set during one request is visible to the next. Safe default: mark anything that holds per-request or per-user state scope: 'request', and let bubbling carry it up the graph. Avoid: caching a user, token, or request id on a singleton service's field.
  • Trusting construction-time input. Threat: a singleton reads configuration or a secret once at construction and never re-checks it. Why: it is built a single time, early. Safe default: resolve values that can change per request or per tenant through request scope, not a singleton constructor. Avoid: treating a value captured at app boot as fresh for the life of the process.

A singleton is shared across every concurrent request. Storing per-request or per-user state on a singleton field is a data-leak bug: request B can read what request A wrote. Use scope: 'request' for anything that must not outlive a single request.

Trade-offs

Why constructor injection with explicit scopes — it makes a class's dependencies visible in its signature and its lifetime a declaration rather than an accident of where new was called.

  • Benefits — a class declares what it needs and stays ignorant of how those are built; tests substitute a fake by resolving from a child container instead of rewriting construction; lifetime is explicit per class; a circular dependency is caught and reported with its full chain (ServiceA → ServiceB → ServiceA) instead of overflowing the stack.
  • Costs — it depends on decorator-metadata emission at compile time; it adds a resolution step at startup (and a per-request child container when request scope is in play); it is one layer of indirection over a plain new.
  • Alternatives — manual wiring is explicit and dependency-free but couples classes to constructors and scatters lifetime decisions; a global service locator decouples construction but hides what a class actually needs behind lookups.
  • Why NextRush chose this — constructor injection keeps dependencies in the type signature where they're discoverable and testable, and wrapping tsyringe rather than exposing it keeps the public surface small: three scopes, constructor injection, cycle detection, and child containers, and no more.

Decision guide

Choose @Service() (singleton) when:

  • ✓ The class has dependencies worth sharing, or is part of a controller graph the container assembles
  • ✓ The instance is stateless, or holds only app-wide state that is safe to share

Avoid the container when:

  • ✗ The class is a pure function, a stateless helper, or a value object with no dependencies — construct it directly
  • ✗ Adding @Service() would be indirection with nothing to resolve

Choose a non-default scope when:

  • scope: 'request' — the instance must hold per-request state (a request id, a per-request cache, a user context) that must not leak across requests
  • scope: 'transient' — every resolve genuinely needs its own fresh instance with no shared state

Common mistakes

  • Building with a bundler that drops decorator metadata. Why it happens: fast bundlers (esbuild, tsx, swc) skip emitDecoratorMetadata by default. Correct approach: build class-based apps with nextrush dev / nextrush build. If ignored: resolution fails with undefined constructor arguments or a vague error, not an obvious "metadata missing" message.
  • Assuming transient means "fresh per request." Why it happens: both sound like "a new one each time." Correct approach: use scope: 'request' when the requirement is one instance per request. If ignored: transient rebuilds on every resolve() — which may happen several times in one request or not at all — so per-request state is either duplicated or missing.
  • Storing per-request state on a singleton. Why it happens: a class is left at the default scope while quietly holding request data on a field. Correct approach: mark it scope: 'request', or depend on a request-scoped class so it bubbles. If ignored: the state is shared across concurrent requests — a correctness and data-isolation bug.

Key takeaways

  • Declaring dependencies as constructor parameters and marking the class @Service() lets the container assemble the graph, so classes stop hard-coding new.
  • Nothing is constructed at decoration time — construction happens at resolve(), and scope decides the instance's lifetime.
  • singleton (default) is built once and shared app-wide; transient is rebuilt on every resolve; request is built once per request and shared within it.
  • Request scope bubbles: a class is effectively request-scoped if anything it transitively depends on is, and such a graph resolves from a per-request child container.
  • A singleton is shared across concurrent requests — never store per-request or per-user state on one.
  • Not every class needs the container; a pure function or dependency-free helper is better constructed directly.

Continue learning

Was this helpful?

On this page