Architecture

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 scopetsyringe 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 signal

The 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 directly

It 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

Was this helpful?

On this page