Skip to content

create Provider style factory for lazy initialization #33

@7frank

Description

@7frank

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:

  1. Container-level lazy instantiation (default): singletons are created on first resolve, not at container boot.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions