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
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:
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:
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');
}
}Observation — AuditLogger 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:
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 withapps/benchmarkbefore 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
emitDecoratorMetadataby default. Correct approach: build class-based apps withnextrush dev/nextrush build. If ignored: resolution fails withundefinedconstructor arguments or a vague error, not an obvious "metadata missing" message. - Assuming
transientmeans "fresh per request." Why it happens: both sound like "a new one each time." Correct approach: usescope: 'request'when the requirement is one instance per request. If ignored:transientrebuilds on everyresolve()— 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-codingnew. - 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;transientis rebuilt on every resolve;requestis 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
Modules
Group providers and controllers, and see how module providers register into the same container.
Class-based development
Put DI to work — controllers, services, and scopes in a real class-based app.
@nextrush/di reference
The full Container, Service, Repository, and error-type signatures.
DI internals
How resolution, scope bubbling, and per-request child containers are implemented.
Guards
How NextRush runs a yes/no access check before a controller handler — so authentication and authorization live in one declared place instead of the top of every handler.
Modules
How @Module groups a feature's controllers and providers behind one declaration, and why registerModule composes them into a single app — without yet enforcing what a module exports.