Dependency injection
Edit docs/architecture/dependency-injection.md on GitHubThe 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:
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:
# bunfig.toml - for `bun run` and `bun test`
preload = ["@dunx/transform/preload"]
[test]
preload = ["@dunx/transform/preload"]bun --preload @dunx/transform/preload src/main.ts # no config file at allawait 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.
| Rung | ms | MiB | adds ms | adds MiB |
|---|---|---|---|---|
bare bun | 3.7 | 12.6 | ||
import 'oxc-parser' alone | 8.9 | 24.7 | +5.2 | +12.1 |
+ @dunx/transform preload | 9.8 | 26.7 | +6.1 | +14.1 |
+ import '@dunx/core' | 11.4 | 27.4 | +1.5 | +0.7 |
+ import '@dunx/http' | 16.9 | 30.2 | +5.5 | +2.7 |
+ HttpFactory.create | 22.3 | 41.3 | +5.4 | +11.1 |
+ listen() | 24.8 | 43.2 | +2.5 | +1.9 |
raw Bun.serve, for scale | 4.4 | 14.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:
- A concrete class. Nothing to declare; an unbound class self-binds.
- 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.
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:
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.createlogs each warning throughLogger.warnand also collects them onApp.warningsfor 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:
- Module import cycles terminate as before -
collectModulesvisits each reference once. - Re-export cycles are the new case:
A exports BandB exports Amakes 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. - Provider cycles are still a boot error with the full path, and must stay one.
forwardRefin 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:
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:
@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:
@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.