Lifecycle

Edit docs/guide/07-lifecycle.md on GitHub

One 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.

TS
@Module({ providers: [UsersService] })
export class UsersModule {}

UsersService is constructed once. Everything resolving from UsersModule's scope receives that instance.

Nestdunx
Scope.DEFAULTthe only lifetime
Scope.REQUESTabsent, and will stay absent
Scope.TRANSIENTabsent, 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:

TS
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:

TS
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.

TS
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.

StageWhat runs
1buildScopes walks the import graph
2overrides replace matching bindings in every scope that holds one
3every provider is constructed, dependencies first
4onBeforeInit() on each instance, in construction order
5onInit() on each instance, in construction order
6shadowing and ambiguous-import warnings are logged

Async factories

useFactory may return a promise. create() awaits it before any dependent is constructed.

TS
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.

TS
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

TS
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

TS
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.

Nestdunx
OnModuleInitOnInit
OnModuleDestroyOnShutdown
onApplicationBootstrapOnInit
beforeApplicationShutdownOnBeforeShutdown

OnBeforeShutdown

Shutdown has two phases, and this is the first one. It runs while the app is still serving.

TS
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

TS
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:

TEXT
CircularDependencyError: Circular dependency: UsersService -> AuditService -> UsersService

error.cycle is the same path as a string[].

Error propagation

FailureSurfaces as
Erased constructor parameter typeboot error naming the parameter
Constructor parameters, no transform recordboot error quoting the bunfig.toml preload
Token no module exportsboot error naming the module that declares it and the one asking
CycleCircularDependencyError with the path
Rejected useFactoryrejects AppFactory.create()
Throwing onInitrejects AppFactory.create()
Throwing onShutdownlogged 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:

TS
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:

TS
app.get(UsersService); // root scope view, then any single declarer
app.get(UsersService, OrdersModule); // prefers OrdersModule's view

app.get finds more than constructor injection does. It looks in this order:

  1. the module you pass, if any
  2. the root module
  3. the module that declares the token, if exactly one does
  4. 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.