Lifecycle hooks (onInit/onDestroy for services, onMount/onUnmount for controllers) do not replace lazy services. Hooks govern what happens after creation; “lazy” governs when creation happens. You still need lazy semantics in the DI to control startup cost, memory, failure locality, and feature gating.
Keep lazy in core — but as a capability, not a marker interface
Do not add a LazyInject marker interface. It’s unnecessary and brittle.
Provide two mechanisms:
-
Container-level lazy instantiation (default): singletons are created on first resolve, not at container boot.
-
Injection-site laziness: inject a provider rather than the instance.
API shape
// Provider pattern (preferred)
type Provider = () => T; // resolves now; constructs on first call
type AsyncProvider = () => Promise; // if construction/init is async
// Container registration
container.register(Token, {
useClass: Impl,
lifetime: 'singleton' | 'scoped' | 'transient',
eager?: boolean, // default false (lazy)
});
const svc = container.resolve(Token); // constructs immediately if first time
const svcLazy: Provider = container.lazy(Token); // constructs on first use
Service lifecycle with lazy
On first construction the container calls onInit() (await if Promise), caches instance.
Subsequent resolve/Provider() calls reuse the initialized instance.
On scope/app teardown, call onDestroy() in reverse dependency order.
When lazy matters (and how often)
Expensive or optional services: analytics SDKs, editors, maps, wasm modules, workers, WebRTC, payment SDKs, IndexedDB, large caches.
Feature/route-gated services: only construct inside feature scope when feature is entered.
Cold-start sensitive apps: defer everything non-critical until after first paint.
Server/Node: defer non-critical connectors until first request or health-check.
Reality: most lightweight services don’t need injection-site laziness; container-level lazy singletons cover ~70–90% of cases. Injection-site providers are used selectively for heavy/rare paths.
When hooks alone are sufficient
Service is cheap AND always needed early.
You need sequencing, not deferral: use onInit to start side-effects once instantiated, but let container create it immediately.
Pitfalls and mitigations
Late failures: lazy defers errors to first use. Mitigate with optional warmup lists (eager: true on critical providers) or an explicit container.init({eager:[TokenA,TokenB]}).
Race on first resolve: multiple concurrent callers must not double-construct. Deduplicate via a shared “resolving” promise in the registry.
Scope capture bugs: a Provider created in one scope must resolve against the current scope at call time, not the creation scope. Implement providers as thunks that look up via a scope handle from context.
Circular dependencies: laziness can hide cycles until runtime. Keep cycle detection in the resolver; error early with a clear graph trace.
Async init semantics: decide policy. Recommended: await onInit during first construction; cache the promise; subsequent callers await the same promise.
Transient with heavy init: avoid; promote to scoped/singleton or use a provider plus explicit dispose.
Memory leaks: long-lived providers capture scope/container. Release on scope onDestroy; ensure provider closures don’t retain dead scopes.
Recommended defaults
Singletons: lazy by default, eager: true opt-in for critical infrastructure.
Scoped: instantiate lazily upon first resolve within scope; destroy on scope end.
Transient: construct on each resolve; discourage heavy onInit here.
Controllers: never lazy at DI level; React controls creation; use onMount/onUnmount.
Minimal implementation sketch
interface Entry {
state: 'unresolved' | 'resolving' | 'ready';
instance?: T;
initPromise?: Promise;
factory: () => T | Promise;
lifetime: 'singleton' | 'scoped' | 'transient';
eager?: boolean;
}
function resolve(token: Token, scope: Scope): T {
const e = scope.lookup(token);
if (e.lifetime === 'transient') return construct(e, scope);
if (e.state === 'ready') return e.instance!;
if (e.state === 'resolving') throw new Error('Use async resolve or provider'); // or await path
e.state = 'resolving';
const p = Promise.resolve(construct(e, scope)).then(async (inst) => {
await inst.onInit?.();
e.instance = inst; e.state = 'ready';
return inst;
});
e.initPromise = p;
// Provide both sync and async APIs; sync callers must not see a half-initialized instance.
throwIfSyncPath(); // or return proxy that queues calls until ready
}
function lazy(token: Token, scope: Scope): Provider {
return () => scope.resolve(token); // resolve at call time, scope-aware
}
Relevance to core
High. Container-level lazy instantiation is a core behavior. Injection-site providers are a small but essential extension. A marker interface is not core and should be avoided.
Lifecycle hooks (onInit/onDestroy for services, onMount/onUnmount for controllers) do not replace lazy services. Hooks govern what happens after creation; “lazy” governs when creation happens. You still need lazy semantics in the DI to control startup cost, memory, failure locality, and feature gating.
Keep lazy in core — but as a capability, not a marker interface
Do not add a LazyInject marker interface. It’s unnecessary and brittle.
Provide two mechanisms:
Container-level lazy instantiation (default): singletons are created on first resolve, not at container boot.
Injection-site laziness: inject a provider rather than the instance.
API shape
// Provider pattern (preferred)
type Provider = () => T; // resolves now; constructs on first call
type AsyncProvider = () => Promise; // if construction/init is async
// Container registration
container.register(Token, {
useClass: Impl,
lifetime: 'singleton' | 'scoped' | 'transient',
eager?: boolean, // default false (lazy)
});
const svc = container.resolve(Token); // constructs immediately if first time
const svcLazy: Provider = container.lazy(Token); // constructs on first use
Service lifecycle with lazy
On first construction the container calls onInit() (await if Promise), caches instance.
Subsequent resolve/Provider() calls reuse the initialized instance.
On scope/app teardown, call onDestroy() in reverse dependency order.
When lazy matters (and how often)
Expensive or optional services: analytics SDKs, editors, maps, wasm modules, workers, WebRTC, payment SDKs, IndexedDB, large caches.
Feature/route-gated services: only construct inside feature scope when feature is entered.
Cold-start sensitive apps: defer everything non-critical until after first paint.
Server/Node: defer non-critical connectors until first request or health-check.
Reality: most lightweight services don’t need injection-site laziness; container-level lazy singletons cover ~70–90% of cases. Injection-site providers are used selectively for heavy/rare paths.
When hooks alone are sufficient
Service is cheap AND always needed early.
You need sequencing, not deferral: use onInit to start side-effects once instantiated, but let container create it immediately.
Pitfalls and mitigations
Late failures: lazy defers errors to first use. Mitigate with optional warmup lists (eager: true on critical providers) or an explicit container.init({eager:[TokenA,TokenB]}).
Race on first resolve: multiple concurrent callers must not double-construct. Deduplicate via a shared “resolving” promise in the registry.
Scope capture bugs: a Provider created in one scope must resolve against the current scope at call time, not the creation scope. Implement providers as thunks that look up via a scope handle from context.
Circular dependencies: laziness can hide cycles until runtime. Keep cycle detection in the resolver; error early with a clear graph trace.
Async init semantics: decide policy. Recommended: await onInit during first construction; cache the promise; subsequent callers await the same promise.
Transient with heavy init: avoid; promote to scoped/singleton or use a provider plus explicit dispose.
Memory leaks: long-lived providers capture scope/container. Release on scope onDestroy; ensure provider closures don’t retain dead scopes.
Recommended defaults
Singletons: lazy by default, eager: true opt-in for critical infrastructure.
Scoped: instantiate lazily upon first resolve within scope; destroy on scope end.
Transient: construct on each resolve; discourage heavy onInit here.
Controllers: never lazy at DI level; React controls creation; use onMount/onUnmount.
Minimal implementation sketch
interface Entry {
state: 'unresolved' | 'resolving' | 'ready';
instance?: T;
initPromise?: Promise;
factory: () => T | Promise;
lifetime: 'singleton' | 'scoped' | 'transient';
eager?: boolean;
}
function resolve(token: Token, scope: Scope): T {
const e = scope.lookup(token);
if (e.lifetime === 'transient') return construct(e, scope);
if (e.state === 'ready') return e.instance!;
if (e.state === 'resolving') throw new Error('Use async resolve or provider'); // or await path
e.state = 'resolving';
const p = Promise.resolve(construct(e, scope)).then(async (inst) => {
await inst.onInit?.();
e.instance = inst; e.state = 'ready';
return inst;
});
e.initPromise = p;
// Provide both sync and async APIs; sync callers must not see a half-initialized instance.
throwIfSyncPath(); // or return proxy that queues calls until ready
}
function lazy(token: Token, scope: Scope): Provider {
return () => scope.resolve(token); // resolve at call time, scope-aware
}
Relevance to core
High. Container-level lazy instantiation is a core behavior. Injection-site providers are a small but essential extension. A marker interface is not core and should be avoided.