Lifecycle
Edit docs/guide/07-lifecycle.md on GitHubOne lifetime, four hooks, eager boot. All of it is @dunx/core and none of it
needs @dunx/http.
Provider lifetime
Every provider is a singleton within the module scope that declares it.
@Module({ providers: [UsersService] })
export class UsersModule {}UsersService is constructed once. Everything resolving from UsersModule's
scope receives that instance.
| Nest | dunx |
|---|---|
Scope.DEFAULT | the only lifetime |
Scope.REQUEST | absent, and will stay absent |
Scope.TRANSIENT | absent, and will stay absent |
Two modules that each list the same class in providers get two instances, one
per scope. Modules covers when that is what you want.
Per-request state
Request-scoped DI was measured and turned down. It is a container's largest source of per-request cost and complexity, and two replacements cover what it was used for.
Correlation data goes through RequestContext, an AsyncLocalStorage that
never touches the container:
export class OrdersService {
constructor(
private readonly context: RequestContext,
private readonly logger: Logger,
) {}
place(order: Order, tenant: string | null) {
// traceId was set by RequestLoggingMiddleware, on the way in
const { traceId } = this.context.getContext();
this.logger.info('placing', { traceId, tenant });
}
}Everything else is a parameter:
const placeOrder = { body: OrderSchema } as const satisfies RouteSchemas;
@Post('/orders', placeOrder)
create({ req, body }: Input<typeof placeOrder>) {
return this.orders.place(body, req.headers.get('x-tenant'));
}Reasoning and the measurement: architecture/dependency-injection.md.
Boot
AppFactory.create() builds the scope graph, instantiates every provider,
awaits every async factory, then runs onInit. It returns a live app; there
is no separate init() call.
const app = await AppFactory.create(AppModule);A wiring error therefore surfaces at boot rather than on the first request that happens to hit the broken route.
| Stage | What runs |
|---|---|
| 1 | buildScopes walks the import graph |
| 2 | overrides replace matching bindings in every scope that holds one |
| 3 | every provider is constructed, dependencies first |
| 4 | onBeforeInit() on each instance, in construction order |
| 5 | onInit() on each instance, in construction order |
| 6 | shadowing and ambiguous-import warnings are logged |
Async factories
useFactory may return a promise. create() awaits it before any dependent is
constructed.
provide(DbConnection, {
useFactory: async (config: AppConfigService) =>
DbConnection.open(config.get('databaseUrl')),
inject: [AppConfigService],
});A rejected factory rejects AppFactory.create(). Nothing partially built is
returned, and onInit never runs.
List the subclass AppConfigService in inject, not ConfigService. TypeScript
rejects a factory parameter typed ConfigService<AppConfig> with
inject: [ConfigService], because the token has no type argument to match it
against.
Configuration covers the as option that declares the
subclass.
OnInit
Implement the interface. No registration.
import type { OnInit } from '@dunx/core';
export class SearchIndex implements OnInit {
async onInit() {
await this.warmCache();
}
}Runs after every provider is constructed, so a dependency is fully built by the
time yours starts. A throwing onInit rejects create().
onInit hooks run in construction order, which follows module import order. Do
not rely on that order. If one provider's onInit needs another provider to be
set up first, do that setup in OnBeforeInit.
OnBeforeInit
import type { OnBeforeInit } from '@dunx/core';
export class Handlers implements OnBeforeInit {
onBeforeInit() {
this.subscribe();
}
}Every onBeforeInit in the app runs before the first onInit, so import order
stops deciding whether a later onInit sees the wiring. EventRegistry uses it
to subscribe every @OnEvent - see Events.
For wiring only. Two implementers in one app are still ordered by construction
order, and opening a socket, arming a timer or starting a worker belongs in
onInit.
OnShutdown
import type { OnShutdown } from '@dunx/core';
export class QueueConnection implements OnShutdown {
async onShutdown() {
await this.client.close();
}
}app.shutdown() runs every onShutdown in reverse construction order, so a
service tears down before the connection it was built on. It is idempotent:
concurrent callers await the same drain, and app.closed resolves once it
finishes.
| Nest | dunx |
|---|---|
OnModuleInit | OnInit |
OnModuleDestroy | OnShutdown |
onApplicationBootstrap | OnInit |
beforeApplicationShutdown | OnBeforeShutdown |
OnBeforeShutdown
Shutdown has two phases, and this is the first one. It runs while the app is still serving.
import type { OnBeforeShutdown } from '@dunx/core';
export class Readiness implements OnBeforeShutdown {
async onBeforeShutdown() {
this.accepting = false;
await Bun.sleep(15_000);
}
}onShutdown runs too late for anything that has to be observable from outside.
@dunx/http stops the server before tearing providers down. A readiness probe
flipped inside onShutdown would answer on an already-closed port, while a load
balancer is still routing traffic to it.
app.drain() runs every onBeforeShutdown first, then closes the port, then
runs onShutdown. shutdown() calls drain() itself, so an app with no HTTP
server still runs onBeforeShutdown.
Every onBeforeShutdown runs concurrently, unlike onShutdown. These are
independent waits, so the phase costs as much as the slowest one rather than
their sum. Teardown, in contrast, follows dependencies and has to run
sequentially.
@dunx/http's HealthModule uses this phase: its drainDelayMs is how long it
waits there. Use the same phase for a queue consumer that must stop taking jobs
before its database closes.
Do not confuse it with @OnDrain(), a websocket handler decorator in @dunx/http
that fires when socket backpressure clears. Different layer, unrelated.
Signals
app.enableShutdownHooks(); // SIGTERM, SIGINT
app.enableShutdownHooks(['SIGTERM']);
app.enableShutdownHooks(['SIGTERM'], { exitAfterMs: false });This ends the process. After the drain completes, an unref()d timer gives
the runtime 1000 ms to exit on its own, then calls process.exit. A process
with nothing pending exits in about 1 ms and the timer never fires. It fires
only when a handle outside the container is still holding the loop open, and
when that happens it logs a warning naming the case before exiting.
Pass exitAfterMs: false when the app does not own its process, and in any test
that fires a signal at its own runner. A programmatic app.shutdown() never
exits the process at any setting.
Circular dependencies
@dunx/transform records constructor dependencies as a thunk evaluated at
resolution time, so a class declared later in the file, or reached across a
circular import, resolves normally. There is no forwardRef to reach for.
A genuine cycle throws CircularDependencyError at boot, carrying the full path:
CircularDependencyError: Circular dependency: UsersService -> AuditService -> UsersServiceerror.cycle is the same path as a string[].
Error propagation
| Failure | Surfaces as |
|---|---|
| Erased constructor parameter type | boot error naming the parameter |
| Constructor parameters, no transform record | boot error quoting the bunfig.toml preload |
| Token no module exports | boot error naming the module that declares it and the one asking |
| Cycle | CircularDependencyError with the path |
Rejected useFactory | rejects AppFactory.create() |
Throwing onInit | rejects AppFactory.create() |
Throwing onShutdown | logged per provider, rejects app.shutdown() after every hook |
Every one of these is a rejected create() except the last. An app that boots
has resolved everything it declares.
A throwing onShutdown does not stop teardown. shutdown() runs every hook in
reverse order, logs each failure with the provider's name, and resolves
app.closed in a finally. Then it rejects with the one error, or an
AggregateError when several providers failed. A throwing onBeforeShutdown is
collected the same way.
Under enableShutdownHooks, ShutdownHooks catches that rejection, logs
[dunx] shutdown failed and arms exit code 1.
Overrides in tests
A test wants the same container with one binding swapped, not a second boot
path built to route around it. createTestApp replaces a binding in place,
in every scope that holds one:
const app = await createTestApp({
modules: [OrdersModule],
overrides: [provide(Clock, { useValue: new FixedClock('2026-01-01') })],
});The discarded provider is never constructed, so an async useFactory that would
have opened the real database does not run.
Overriding a token that nothing binds throws, so a test cannot silently check a provider it never replaced. A class is the exception: overriding an unbound class is accepted, because any class can be injected without being listed.
Full harness, including createTestServer and RecordingLogger:
Testing.
Reaching the container
The container itself is reachable too, for wiring and debugging code that sits outside constructor injection:
app.get(UsersService); // root scope view, then any single declarer
app.get(UsersService, OrdersModule); // prefers OrdersModule's viewapp.get finds more than constructor injection does. It looks in this order:
- the module you pass, if any
- the root module
- the module that declares the token, if exactly one does
- if no module declares it and it is a class, it registers the class in the module you passed, or in the root module
It throws if two modules declare the token, or if the module you pass is not part of the app.
AppRef is the injectable form, and is dunx's ModuleRef.