Skip to content

Dependency Injection, Scopes & Lifecycle

Guide 04

The application context is Ndigo’s dependency container. It owns every bean, decides how long each one lives, injects the collaborators a bean asks for, and closes resources in a defined order when the app stops. This guide covers the registration surface, the scope strategies, and the lifecycle hooks.

Audience: Node and browser application developers · Prerequisites: Guide 01 (Node) or guide 02 (browser) · Reference app: apps/05-ndigo-api

  • Register beans with @configuration classes, @entry methods, and tokens.
  • Choose between singleton, prototype, hash, refresh, and custom scopes.
  • Use the aware callbacks to receive the context and value name.
  • Implement init, destroy/close, and smart-lifecycle phases.
  • Use parent/child contexts and application events without leaking state.

An application context is a named, hierarchical container. A bean is any value the context owns under a name or a ContextToken. The boot presets create the root context for you; the container API is the same everywhere.

import { GenericApplicationContext, ContextToken } from '@ndigo/context';
const context = new GenericApplicationContext('app');
const CLOCK = new ContextToken<Clock>('clock');
context.setValue(CLOCK, new SystemClock());
context.hasValue(CLOCK); // true
const clock = context.getValue(CLOCK);

Tokens are preferable to string names: they carry the value’s type and avoid collisions between packages. getValue on a name that is not registered throws; hasValue is the guard. Values registered with setValue are instance values — they are not re-created by a scope.

Most beans are declared in a configuration class. @configuration marks the class as a source; @entry marks a method whose return value becomes a bean. The method name is the bean name unless a token or explicit name is supplied.

import { configuration, entry } from '@ndigo/context';
@configuration
class AppConfiguration {
@entry
clock(): Clock {
return new SystemClock();
}
@entry
reportService(): ReportService {
return new ReportService(this.clock());
}
}

Entries are zero-argument factories: they run once when the context is prepared, and their return value is registered under the method name (or the supplied token/name). Compose inside the factory method, or return a class to register a constructor and let the scope strategy create instances lazily. The boot presets accept an array of configuration classes:

import { runNodeApplication } from '@ndigo/platform-node';
const app = await runNodeApplication([AppConfiguration], { port: 3000 });

A class can also act as its own factory — the constructor-factory dual pattern: register the class and let the container construct it, or register a factory that returns it. The container does not care which shape produced the value; both are beans.

Scopes decide how long a bean lives. Declare one with a class decorator from @ndigo/factory:

Scope Lifespan Use
singleton (default) One instance per factory Services, configuration, pools
prototype A new instance on every getValue Short-lived collaborators
hash / route One instance per active scope key, torn down at route end Per-route request state
refresh Cached until config:refresh, then re-created Configuration-derived clients
custom Whatever the registered Scope implements App-specific lifetimes
import { singletonScope, prototypeScope, refreshScope, hashScope } from '@ndigo/factory';
@singletonScope
class ConnectionPool {
/* one per application */
}
@prototypeScope
class RequestContext {
/* a fresh instance per resolution */
}
@hashScope
class RouteState {
/* one per active route key */
}
@refreshScope
class FeatureClient {
/* rebuilt after the environment refreshes */
}

@refreshScope is the bridge to guide 03: when the environment emits config:refresh, the cached instance is destroyed and the next getValue re-creates it. A custom scope is registered on the factory with registerScope(name, scope); the scope owns its own cache and destroyValue(name) behavior.

Scope pitfalls:

  • A scope decorator belongs on the class, not on the @entry method.
  • prototype beans are not cached, so nothing calls their destroy/close automatically; the caller owns them.
  • hash scope requires an active key (HashScope.setCurrentKey); resolving outside a route scope fails loudly.
  • refresh destroys the old instance; do not hold a reference to it across a refresh.

An aware bean receives a framework object after construction. The context runs the aware processors before the bean’s init hook. The common one is the application context itself:

import { AsApplicationContextAware } from '@ndigo/context';
import { Null } from '@ndigo/core';
class ReportService extends AsApplicationContextAware(Null) {
describe(): string {
return this.applicationContext?.name ?? 'unbound';
}
}

NameAware (from @ndigo/factory) receives the value name. The mixins (AsApplicationContextAware, AsNameAware, AsAware) add the marker symbol and the setter; implementing the interface directly works too if you set the marker. Awareness is an integration point, not a service locator: prefer constructor arguments for ordinary collaborators and use aware callbacks only when the framework object is genuinely ambient.

Four contracts cover the common lifecycle shapes:

Contract Method When it runs
Initializable init After construction and aware processing
Destroyable destroy When the bean is destroyed or the context closes
Closeable close Same as destroy, for close-shaped APIs
SmartLifecycle start / stop On context refresh/close, by phase
import type { Initializable, Closeable, SmartLifecycle } from '@ndigo/factory';
class Pool implements Initializable, Closeable {
#closed = false;
init(): void {
// open connections
}
close(): void {
if (this.#closed) return;
this.#closed = true;
// release connections
}
}
class Heartbeat implements SmartLifecycle {
readonly isAutoStartup = true;
readonly phase = 100;
isRunning = false;
start(): void {
this.isRunning = true;
}
stop(): void {
this.isRunning = false;
}
}

SmartLifecycle beans start in ascending phase order on refresh and stop in descending phase order on close. Use a lower phase for infrastructure that others depend on and a higher phase for consumers. isAutoStartup: false opts out of automatic start; the context still calls stop if the bean is running.

Teardown is idempotent: destroy/close may run more than once, so guard the body. The container calls destroy when present, otherwise close; implementing both is unnecessary. Async teardown is not awaited by the synchronous context.close — bridge an async resource to a Closeable that drains first (guide 21) or close it explicitly before the context.

A child context inherits lookups from its parent but keeps its own values and lifecycle. Micro-frontend features use this shape:

import { GenericApplicationContext } from '@ndigo/context';
const shell = new GenericApplicationContext('shell');
const feature = shell.newChild('catalog');
feature.setValue('catalogTitle', 'Catalog');
shell.hasValue('catalogTitle'); // false — children do not leak upward

Events travel through the tree explicitly:

  • context.emit(type, payload) — local listeners.
  • context.broadcast(type, payload) — every descendant.
  • context.bubble(type, payload) — every ancestor.

Use application events for cross-context notification (a feature mounted, a configuration refreshed), not for durable work — that is jobs (guide 22) — and not for local derived state — that is signals (guide 29). ContextEventGateway adds an allowlisted relay between a child and a shell; guide 31 covers the micro-frontend wiring.

Closing a context closes its children depth-first, then stops its lifecycle processor, destroys its beans, and detaches from the parent. close is a safe no-op after the first call.

  • Registering an instance with setValue bypasses scopes. It is an instance value for the lifetime of the context; use @entry for scoped beans.
  • Circular constructor dependencies fail at refresh. Break the cycle with a setter/aware callback, a provider method, or a separate collaborator.
  • A bean captured before refresh is not initialized. Resolve beans after the context is active; boot presets return the refreshed context.
  • Async cleanup is not automatic. Bridge to Closeable and drain before context.close.
  • Do not use the context as a global. Pass it only to infrastructure that genuinely needs the container; application services should take their collaborators as constructor arguments.