Dependency Injection Internals
How @nextrush/di wraps tsyringe — the container adapter, scope mapping, circular dependency detection, and the tsyringe coupling boundary.
This page explains how @nextrush/di works internally. For usage — registering services,
choosing a scope, resolving dependencies — see Dependency Injection.
Every claim below is verified against packages/di/src/container.ts, packages/di/src/injection.ts,
and packages/di/src/index.ts.
Source & internals
A wrapper, not a reimplementation
@nextrush/di's own package doc comment describes itself as a "lightweight wrapper around
tsyringe with enhanced error handling." The container you interact with
(import { container } from '@nextrush/di') is a wrapper object built by
createContainerWrapper() around a real tsyringe DependencyContainer — tsyringe does the
actual reflection-based resolution; NextRush's layer adds typed errors, scope-name mapping, and
a small number of behaviors tsyringe doesn't provide natively.
The container wrapper's own state
createContainerWrapper() closes over three pieces of state that don't exist in raw tsyringe:
import type { Token } from '@nextrush/types';
const resolutionStack = new Set<string>(); // in-flight resolution names, for cycle detection
const factoryTokens = new Set<Token>(); // tokens registered via useFactory, for bootstrap()
const bootstrappedValues = new Map<Token, unknown>(); // cached results of bootstrap()None of this lives inside tsyringe itself — it's bookkeeping NextRush's wrapper maintains
alongside the underlying DependencyContainer instance it was constructed from.
Scope mapping: NextRush's three scopes → tsyringe's registration calls
register() inspects the provider and, for a class provider, resolves the scope (explicit
options.scope, or the class's own @Service/@Repository decorator metadata via
getServiceScope, defaulting to 'singleton') and maps it directly onto a tsyringe call:
| NextRush scope | tsyringe call |
|---|---|
'singleton' | tsyInstance.registerSingleton(token, useClass) |
'request' | tsyInstance.register(token, { useClass }, { lifecycle: Lifecycle.ContainerScoped }) |
'transient' (or any other) | tsyInstance.register(token, { useClass }) (tsyringe's default) |
Request scope is implemented as tsyringe's ContainerScoped lifecycle: one instance per
container, not per-request in tsyringe's own vocabulary. The container.ts source comment is
explicit about how this becomes per-request: "A per-request child (createChild()) constructs
its own instance and shares it within that request; singletons stay on the parent." The
per-request behavior is a property of NextRush creating a fresh child container per HTTP
request (see the class runtime's request-scope machinery), not something ContainerScoped does
on its own.
Circular dependency detection is NextRush's own layer, not tsyringe's
resolve() tracks in-flight resolutions in resolutionStack and checks membership before
calling into tsyringe:
import { CircularDependencyError } from '@nextrush/di';
const resolutionStack = new Set<string>();
const tokenName = 'ExampleToken';
if (resolutionStack.has(tokenName)) {
const cycle = [...resolutionStack, tokenName];
throw new CircularDependencyError(cycle);
}
resolutionStack.add(tokenName);This catches same-token re-entrant resolution directly with an O(1) Set lookup, independent of
whatever tsyringe itself does. For cycles tsyringe's own resolution surfaces as an error (a
different token chain that still forms a cycle), the wrapper post-processes tsyringe's thrown
error message to decide whether to re-throw as CircularDependencyError:
// container.ts — resolve()'s catch block, in order of confidence:
// (a) tsyringe recursion blew the JS stack (RangeError / "maximum call stack")
// (b) the message explicitly names a circular/cyclic dependency
// (c) a nested "Cannot inject the dependency" chain with NO missing-token signalThe source comment calls out a specific ordering requirement: the missing-dependency check (for
messages containing "not registered", "cannot resolve", or "unregistered") must run
before the "Cannot inject the dependency" heuristic, because tsyringe's own message for a
missing constructor dependency contains both phrases — checking the wrong one first would
misreport a genuinely missing registration as a circular dependency.
bootstrap() resolves factory-registered tokens eagerly, and is safely re-runnable
Any token registered via useFactory is tracked in factoryTokens. bootstrap() resolves each
one and caches the result in bootstrappedValues, awaiting it first if the factory returned a
Promise. The source comment explains why this is designed to be called more than once in the
same process: createApp() + registerControllers() can run through multiple registration
cycles sharing the global container, so bootstrap() must stay idempotent. It deliberately does
not clear factoryTokens after running — already-resolved factories are skipped via the
bootstrappedValues cache, and any factory registered after an earlier bootstrap() call (or
whose cache was dropped by clearInstances()) is picked up on the next call. Iteration runs over
a snapshot ([...factoryTokens]) specifically so a factory that registers another factory during
its own resolution can't mutate the set mid-loop.
The one isolated point of coupling to tsyringe's private internals
@Optional() (in injection.ts) needs a capability tsyringe has no public API for: making an
unregistered dependency resolve to undefined instead of throwing. The implementation reaches
into tsyringe's own metadata shape directly, and the source comment names this as a deliberate,
contained risk:
// "Deliberate, isolated coupling to tsyringe internals."
const TSYRINGE_INJECTION_KEY = 'injectionTokens';
// ... reads/mutates the descriptor's isOptional flag directlyIt is written defensively: wrapped in try/catch so a future tsyringe version that changes
this descriptor's shape degrades to a no-op rather than throwing, with the source comment noting
that the corresponding end-to-end @Optional() test is what actually catches that kind of
breakage at CI time — not a runtime assertion.
Errors are typed, not tsyringe's raw error strings
Every error the wrapper surfaces is one of CircularDependencyError, DependencyResolutionError,
or InvalidProviderError (all extending DIError, defined in packages/di/src/errors.ts) — the
caller never has to pattern-match on a tsyringe error message directly. This is the "enhanced
error handling" the package's own doc comment promises: tsyringe's resolution machinery is real,
but the error contract at the @nextrush/di boundary is NextRush's own.
createContainer() isolation is partial — read the source comment
createContainer() returns a wrapper around a child of the global tsyringe container, reset
immediately:
import type { Container } from '@nextrush/types';
// tsyringe's own container type — @nextrush/di wraps it directly
declare const tsyContainer: { createChildContainer(): DependencyContainer };
interface DependencyContainer {
reset(): void;
}
declare function createContainerWrapper(tsyInstance: DependencyContainer): Container;
export function createContainer(): Container {
const childTsy = tsyContainer.createChildContainer();
childTsy.reset();
return createContainerWrapper(childTsy);
}The function's own doc comment is explicit that this is not a from-scratch isolated container: "Creates a child container from the global tsyringe container. For truly isolated containers, reset the child before use" — which is exactly what the implementation already does, but the comment is flagging that a child container still shares tsyringe's underlying registration mechanics with its parent chain, which matters if you're relying on this for test isolation across a large suite.
Next steps
Framework Comparison
Side-by-side code comparison of NextRush, Express, Fastify, Hono, and Koa — routing, middleware, error handling, and TypeScript support.
Middleware Flow
Internal architecture of NextRush's compose() engine — dispatch mechanics, ctx.next() wiring, error propagation, snapshotting, and the callback() pipeline.