Dependency injection

Edit docs/architecture/dependency-injection.md on GitHub

The decorator dialect, how constructor types are recorded without metadata, and why modules group rather than encapsulate.

The decorator dialect decision

tsyringe and @Inject()-style constructor injection are locked to legacy experimentalDecorators permanently. TC39 standard decorators have no parameter decorators, so constructor(@inject(X) x: X) has no migration path. TypeScript itself is walking away from that dialect. Building a new framework on it anyway, just to buy a lossy metadata table you then have to work around, is a bad trade.

dunx uses standard decorators only. The root tsconfig.json must not set experimentalDecorators or emitDecoratorMetadata.

That rules out the decorator route to constructor injection. It does not rule out constructor injection - see below.

Constructor injection without decorator metadata

An earlier draft of this document concluded that constructor injection was unavailable, and that inject() in field initializers was the only option. That conclusion was wrong. It assumed the parameter types had to be recovered at runtime, the only thing decorators could have done. They can be read at load time instead, from the source that still has them.

@dunx/transform is a Bun plugin. On load it parses each file with oxc-parser, reads every class's constructor parameter types, and appends one statement per class:

TS
export class UsersService {
  constructor(private readonly repo: UsersRepository) {}
}
Object.defineProperty(UsersService, Symbol.for('dunx.deps'), {
  value: () => [UsersRepository],
});

The container reads that record and resolves the arguments before calling new. So the user-facing syntax carries no annotation at all - no @Injectable, no @Inject, no inject(), no reflect-metadata, no experimentalDecorators.

Registering the transform

Three ways, same plugin object:

TOML
# bunfig.toml - for `bun run` and `bun test`
preload = ["@dunx/transform/preload"]

[test]
preload = ["@dunx/transform/preload"]
SH
bun --preload @dunx/transform/preload src/main.ts   # no config file at all
TS
await Bun.build({ entrypoints: ['src/main.ts'], plugins: [depsPlugin] });

@dunx/create-app scaffolds the first of these, so a generated app needs no setup.

Why @dunx/core does not register it itself

The obvious "built in" move is for @dunx/core to call Bun.plugin() when it is imported. It was rejected twice over.

It would make DI import-order dependent. Bun's onLoad only affects modules loaded after registration, and static imports are evaluated depth-first in source order. import { AppFactory } from '@dunx/core' before import { AppModule } from './app.module.js' would work; the reverse order would silently skip the transform. That reproduces the "reflect-metadata must be the first import" fragility this framework exists to avoid, and no amount of documentation fixes an ordering rule.

It would also cost @dunx/core its empty dependency list. The transform needs oxc-parser, a native binary. Every production deployment would then carry it, in order to run code that was already transformed at build time.

What the preload costs at boot

Measured on Bun 1.4.2, a 5950X, median of 11 cold starts, each rung a whole process adding one step to the one above it. The oxc-parser row is the exception: it is measured against bare bun, so it splits the preload below it rather than stacking on it.

These stop when the process does, not when it has served something, which is a different milestone from the one docs/guide/01-introduction.md publishes and about 20 ms earlier on both subjects. Neither table is wrong: this one apportions the boot, and the guide's states what a caller waits for. The apps differ too - the rungs above are minimal, and the benchmark harness's subjects import zod.

RungmsMiBadds msadds MiB
bare bun3.712.6
import 'oxc-parser' alone8.924.7+5.2+12.1
+ @dunx/transform preload9.826.7+6.1+14.1
+ import '@dunx/core'11.427.4+1.5+0.7
+ import '@dunx/http'16.930.2+5.5+2.7
+ HttpFactory.create22.341.3+5.4+11.1
+ listen()24.843.2+2.5+1.9
raw Bun.serve, for scale4.414.6

The preload is the largest single item in memory, at 49% of what a dunx process holds over a raw Bun.serve one, and it is paid before any application code runs: the rung above adds it to a script with no classes in it at all. oxc-parser on its own accounts for 12.1 of those 14.1 MiB, measured the same way; the rest is the plugin and its registration.

Time ranks differently, and the two should not be read as one number. Of the 21.1 ms over the baseline, the preload is 6.1, import '@dunx/http' is 5.5 and HttpFactory.create is 5.4. Those three are within a millisecond of each other, so anyone reducing boot latency has three targets rather than one, and the import is as large as the container.

Memory is the lopsided one: 14.1 for the preload and 11.1 for the container and the resolved provider graph, against 2.7 for that same import.

The preload half is already avoidable for a compiled deployment. examples/binary calls Bun.build({ compile, plugins: [depsPlugin] }), which applies the transform during the build and bakes the records into the binary, so the artifact carries no parser and needs no preload. That is the Bun.build API with depsPlugin rather than a plain bun build --compile, which would produce a binary with no records in it at all. So the 14.1 MiB is a cost of the bun run deployment shape rather than of the design, and the two can now be priced against each other.

So registration stays explicit, and the failure mode is closed off instead:

The missing-transform guard

Function.prototype.length still reports the declared parameter count after TypeScript's parameter properties are compiled away (measured: a constructor with two parameter properties has length === 2). So the container can tell the difference between "this class needs nothing" and "nobody told me what this class needs". No recorded dependencies plus a non-zero arity means the plugin never saw the file. That raises a boot error that carries the fix:

UsersController declares 1 constructor parameter(s) but no dependencies were
recorded for it, so @dunx/transform did not transform UsersController.
Register the plugin, then retry:

  # bunfig.toml
  preload = ["@dunx/transform/preload"]

The check cannot produce a false positive, for three reasons. A constructor whose parameters all have defaults has length === 0 and is genuinely callable with no arguments. A class bound with useValue is never constructed. And the transform only ever writes a record whose length equals the parameter count, so a present record is never empty.

Three properties follow, and each is strictly better than the emitDecoratorMetadata equivalent measured above:

Erased types are named rather than degraded. emitDecoratorMetadata turns an interface into Object and a primitive into Number, so Nest needs @Inject(TOKEN) for both. The transform can see the difference in the source. So it records the parameter as unresolved along with its original text, and the container throws at boot naming it:

UsersService cannot be constructed: parameter 2 (private readonly cfg: AppConfig)
names nothing that exists at runtime, so there is no token to resolve.

A type-only import, an inline type specifier, a local interface or type alias, a class type parameter, a primitive, and a union are all detected this way.

The record is a thunk, so there is no temporal dead zone. An eagerly evaluated array would crash on a dependency declared later in the file or reached through a circular import. Deferring the body to resolution time removes the need for forwardRef.

Inheritance falls out of the prototype chain. readDeps does a plain lookup rather than Object.hasOwn, so a subclass that declares no constructor inherits its base's constructor and its base's dependencies. A subclass that does declare one gets its own record, which shadows the base's.

Two limits. The transform only rewrites class declarations: a ClassExpression's own name is bound inside the class body, so a statement appended after const X = class Inner {} could not reference Inner.

Also, because a plugin sees one file at a time, it cannot tell a DI provider from a plain data class - new HttpError(404, 'x') also has constructor parameters. So it records metadata for every annotated class and lets the container raise the error only if something is actually resolved as a provider. The error surfaces at boot, rather than at build time.

inject() remains available for a value with no constructor parameter to hang off, and both mechanisms may be used in the same class.

Core primitives (@dunx/core)

Usage lives in the guide: Providers covers constructor injection, inject(), token() and provide(), and Modules covers @Module, exports, global and forRoot. This section keeps the reasoning the guide does not.

There is no @Injectable() - every class is injectable by default. AppFactory.create() is async and there is no separate init(): resolution is eager, so an app that exists is an app that booted.

token() is the escape hatch rather than the default

Anything that is a runtime value can be its own token, so most code needs no token() call at all. In order of preference:

  1. A concrete class. Nothing to declare; an unbound class self-binds.
  2. An abstract class for a contract whose implementation is built elsewhere. It is a runtime value, so it works as a token. Bind it to an implementation: an unbound abstract class self-binds like any other class.
  3. token<T>(name) only for what has no runtime value to name: a primitive (token<string>('Dsn')), or a value whose type you do not own and cannot subclass.

An interface is erased at compile time, so inject(SomeInterface) cannot exist - that is the same erasure the emitDecoratorMetadata measurement above shows degrading to Object. Replacing the interface with an abstract class removes the token entirely. examples/full keeps its token() calls to src/wiring/tokens.ts, for a value type and a primitive.

token<T>() returns a unique object; the name is only a label for error messages. Two token<Config>('config') calls are two distinct tokens, so nominal collision is impossible and no prefixing convention is needed. Class tokens key on the constructor reference, so two same-named classes never collide either.

One erasure cost to know: abstract does not exist at runtime, so an abstract class that is injected but never bound gets self-bound and constructed into a useless object rather than erroring. TypeScript blocks it in the providers array (a bare entry must be constructible), but not at the inject() call site.

@Module is a marker, and provide() is a call

@Module writes its options onto the class as a Symbol.for('dunx.module') property - the same technique as route discovery, no accumulator. The class is never instantiated. Reading it uses Object.hasOwn, so subclassing a module does not inherit its bindings; that throws instead.

provide() stays a call rather than a { provide, useValue } literal because per-element type inference across a heterogeneous array requires one: that is precisely why Nest's useValue is untyped, and dunx's is checked against the token's type.

Resolution mechanism

Constructor arguments are resolved before the injector swap. Argument resolution recurses back through get() and must not see the class being built as its own scope. Then a module-level currentInjector is set around the new Klass() call itself, so any inject() in a field initializer resolves against it. Field initializers run synchronously inside the constructor, so there is no async gap and no AsyncLocalStorage cost. Calling inject() outside construction throws with a clear message.

Both paths go through the same get(), so cycle detection and the async-factory retry below apply identically whether a dependency arrived as a constructor parameter or an inject() call.

Eager-only, no lazy resolution

AppFactory.create() instantiates every provider and awaits async factories before the server binds, so wiring errors surface at boot rather than at first request. That lets inject() stay synchronous: by the time any constructor runs, every async provider is already resolved.

There is no static graph to topologically sort - inject() calls are only discovered by running the field initializers. So construction is recursive and synchronous.

When an async factory is reached from inside a constructor, it parks its promise, throws a private signal to unwind, and the async caller awaits the token and retries the construction. Each retry resolves at least one more async binding, so it terminates in at most one pass per async dependency. A factory is never invoked twice, because the promise is parked before the signal is thrown.

The cost is that a constructor aborted this way runs its already-evaluated field initializers again, so field initializers must stay pure wiring.

For the same reason a factory cannot use inject(): after its first await the module-level current injector is no longer its own. Factory dependencies are declared instead, and inferred into the factory's parameters:

TS
provide(Database, {
  useFactory: connect, // (config: Config) => Promise<Database>, inferred
  inject: [Config],
});

Singleton lifetime only

Request-scoped DI is Nest's single biggest source of complexity and per-request cost. Per-request state is passed as an explicit ctx argument instead. Request-scoped context (logging correlation and similar) stays a separate AsyncLocalStorage concern that never touches the container.

Cycle detection

A building set tracks in-flight construction and throws with the full cycle path. Without it, a field-initializer cycle is an unbounded recursion with an unreadable stack.

Modules encapsulate: exports, global, and a scope each

This replaced a flat container. The section that argued for the flat design is worth reading in the history rather than here. The short version: the flat container had no exports, no visibility boundary, and therefore no "provider is not exported from module X" error - and it was defended on those grounds.

What it could not express was module-scoped middleware: a module providing a guard for its own routes, resolved from its own scope, or per-module rebinding. A DI framework is expected to have both. Nobody was consuming dunx yet, so the reversal cost no migration.

The model is three concepts.

A scope is a module. Every module reference in the graph gets its own set of bindings. Two different configurations of one class are two scopes, which is consistent with the per-reference deduplication collectModules already did.

exports is visibility. A module lists the tokens an importer may resolve; everything else stays private. It accepts a token or a ModuleRef, and a ModuleRef re-exports whatever that module exports. That lets DatabaseModule pass the drizzle handle on, so a feature module imports it rather than @dunx/infra/db.

global: true publishes a module's exports to every scope with no import needed. ConfigModule and LoggerModule are global because configuration and the Logger contract are read everywhere.

@dunx/http's own wrapper is global for a different reason: it imports the app's root rather than being imported by it, so without global scope its PubSub would be invisible to the whole app.

Resolution, and why it is still one lookup

For a token requested while constructing a provider in module M: M's own providers, then the exports of what M imports, then the global scope. Local shadows imported, the rebinding this exists to allow.

Visibility is flattened once at boot into one Map per scope. Walking an import chain per lookup would make every construction O(depth). Computing the closure once keeps resolution the single Map.get it was when the container was flat, and moves the cost to boot instead, the trade this architecture makes everywhere else. A boot-time regression was accepted for the encapsulation.

Instance caches key on the binding rather than the token, so two modules that each declare one class hold two instances.

What replaced the duplicate-binding check

It could not survive intact, because repetition across scopes is the feature. It split into three:

  • The same token twice in one module is still an error.
  • Two modules binding one token is legal and silent.
  • An importer seeing one token from two imports is legal. It takes the last and warns, naming both modules, but only when the bindings actually differ - a diamond re-export stays silent. AppFactory.create logs each warning through Logger.warn and also collects them on App.warnings for programmatic access.

Nest is silent here and it costs people hours, so the warning exists.

Circular imports still need no forwardRef

That never came from flatness. It comes from the deps thunk: @dunx/transform writes Symbol.for('dunx.deps') as a function and readDeps calls it at resolution, so a circular ES import already resolves and scoping does not touch it.

Three cycles, only one of them new:

  1. Module import cycles terminate as before - collectModules visits each reference once.
  2. Re-export cycles are the new case: A exports B and B exports A makes each export set depend on the other. It is not a real cycle, because an export set is a union and union is monotonic, so the sets are computed to a fixed point.
  3. Provider cycles are still a boot error with the full path, and must stay one. forwardRef in Nest largely exists to paper over exactly this.

Resolution order comes from the provider graph rather than the module graph, which lets a module cycle whose providers have no cycle resolve fine.

app.get() is more permissive

Constructor injection is strict. app.get(token) tries the root scope's view, then any single module that declares the token, and errors on ambiguity. app.get(token, Module) prefers that module's view and falls back to the same search.

It is a bootstrap and debugging call rather than a dependency edge. Requiring every caller to know the owning module would make exports painful for no safety gain.

forRootAsync factories take imports

A factory passed to forRootAsync is written at the call site but produces a provider in the configured module's scope - so one injecting DbConnection is asking a library module to resolve a token only the app can see. AsyncModuleConfig adds imports for that, the field Nest's forRootAsync({ imports }) fills. A token from a global module needs nothing, so most factories carry no imports at all.

The error is the feature

exports reintroduces the most complained-about error in the Nest ecosystem, so dunx answers it from the whole graph, which is known at boot:

Cannot resolve UsersRepository for ReportsService in module "ReportsModule".
"UsersModule" declares it and "ReportsModule" imports that module, but it does not
export UsersRepository. Add UsersRepository to that module's exports, or move the
provider into "ReportsModule".

Every branch is answerable: declared-but-not-exported, declared-but-not-imported, declared-nowhere.

Two findings from the migration are baked in. A class token that nothing visible binds no longer silently self-binds when some module declares it - that used to report "did the transform run?" instead of the real problem. And a module exporting a token it neither declares nor can reach is caught where the mistake is, rather than in some other module.

exports of a module reference is a structural test

Re-exporting a module reference is what makes a facade module work. The reference is almost always a DynamicModule from a static factory whose class carries no @Module decorator: MailerModule in the guide, and DbModule, RedisModule, AuthModule, every configured module in @dunx/infra.

Classifying an exports entry with isModuleRef, which demands the marker, therefore rejected the exact case the feature exists for, and reported it as an unresolvable token named undefined.

isModuleExport is the classifier the export path uses instead: a function is a module only if decorated, an object is one if it has a module function property. The alternatives are disjoint, so nothing else can match - a Token is { description }. findRootModule keeps the strict isModuleRef, because recognising a root among a file's arbitrary exports is the whole job there.

Found by migrating dunx-template, the job an acceptance app exists for.

Overrides replace in every scope

createTestApp({ overrides }) replaces a token's binding in every scope that holds it. A suite stubbing Logger should not have to know how many modules bind it, and making it name a scope would push container topology into every test.

"An override that replaced nothing is an error" still holds, and matters more here: it catches an override aimed at a token that has moved behind a boundary.

Scoping governs what a class can resolve. What happens once a request reaches one is a separate concern: middleware.

One extension point in place of five

Nest has middleware, guards, interceptors, pipes, and filters. dunx has:

TS
interface Middleware {
  handle(
    req: BunRequest,
    ctx: RouteContext,
    next: () => Promise<Response>,
  ): Promise<Response>;
}

A guard is still middleware that throws - there is no CanActivate. What @UseGuards, @Roles and @Public added is not a sixth extension point, but a scope and a metadata channel for the one that already existed. ctx.get(key) reads metadata merged handler-first-then-class at discovery, so a method-level @Public() overrides a class-level @Roles().

Guards are middleware that throw. Interceptors wrap next(). Pipes become schema validation in the route options. Filters become one error mapper. Chains compose into a single closure per route at boot - no per-request array iteration.

Params without parameter decorators

Standard decorators have no parameter decorators, so @Body() / @Query() are off the table. Schemas move onto the route decorator:

TS
@Post('/', { body: CreateUser, query: Paging })
create(req: BunRequest<'/users'>, input: { body: CreateUser; query: Paging }) {}

Validation targets the Standard Schema spec (~standard.validate), so Zod 4, Valibot, and ArkType all work with zero dependencies in @dunx/*.

That covers what arrives on a request. The remaining piece is how a module configures itself before any request exists.

A decorated module that also configures itself

resolveRef merges a DynamicModule's options with the @Module metadata on the class it names, and a provider declared in both resolves to the one forRoot() returned. That override is the feature, not a leftover:

TS
@Module({
  providers: [provide(Settings, { useValue: new Settings('default') })],
})
class Configurable {
  static forRoot(value: string): DynamicModule {
    return {
      module: Configurable,
      providers: [provide(Settings, { useValue: new Settings(value) })],
    };
  }
}

The decorator carries the default and the factory carries the configured value, so one class serves both an app that configures it and a test that does not. module-compose.test.ts pins it.

It used to be a duplicate-binding error, because the two lists were concatenated. union and unionProviders replaced concat, keying on the token so a collision resolves rather than throws.

Making that collision an error again was proposed and implemented. It broke three of those tests: forbidding the collision forbids the feature, since the collision is how a default gets replaced. If the silent half ever needs surfacing, it is a warnings entry naming the class and the token, not an error.