@fluojs/di
v3.1.2
Published
Minimal token-based dependency injection container powering every Fluo application.
Maintainers
Readme
@fluojs/di
Node.js support is >=24.0.0 <27. See Node.js support and migration before upgrading.
Minimal token-based dependency injection container powering every fluo application.
Table of Contents
- Installation
- When to Use
- Quick Start
- Key Capabilities
- NestJS Scope and Optional Dependency Migration
- Circular Dependency Handling
- Testing and Mocking
- Internal Package Integrations
- Troubleshooting
- Public API
- Related Packages
- Example Sources
Installation
npm install @fluojs/diWhen to Use
Use this package when you need to:
- Resolve classes and their dependencies at runtime.
- Manage object lifetimes (Singleton, Request, Transient).
- Override implementations for testing or environment-specific needs.
- Create isolated request-scoped containers for HTTP or background tasks.
Quick Start
The container resolves tokens into instances based on their registered providers.
import { Container } from '@fluojs/di';
import { Inject, Scope } from '@fluojs/core';
class Logger {
log(msg: string) { console.log(msg); }
}
@Inject(Logger)
@Scope('singleton')
class UserService {
constructor(private logger: Logger) {}
async getStatus() {
this.logger.log('Checking status...');
return { status: 'active' };
}
}
const container = new Container();
container.register(Logger, UserService);
const service = await container.resolve(UserService);
const result = await service.getStatus();Key Capabilities
Provider Types
fluo DI supports four provider shapes:
- Class Providers:
container.register(MyService)or{ provide: MyToken, useClass: MyService }. - Value Providers:
{ provide: 'API_URL', useValue: 'https://api.example.com' }. - Factory Providers:
{ provide: 'ASYNC_CONFIG', useFactory: async (db) => await db.load(), inject: [Database] }. AddresolverClasswhen the factory should inherit the referenced class's DI metadata, such as@Scope(...), unless an explicit providerscopeis set. - Alias Providers:
{ provide: ILogger, useExisting: PinoLogger }allows mapping one token to another existing provider.
Typed public token resolution
Symbols created by publicToken<T>() from @fluojs/core infer the return type
of resolve(). Names and metadata registries do not unify distinct constructors;
an explicit alias points to the existing provider.
import { publicToken } from '@fluojs/core';
import { Container } from '@fluojs/di';
class PostsService { title() { return 'FluoBlog'; } }
const POSTS = publicToken<PostsService>('my-blog/posts/v1');
const container = new Container().register(
PostsService,
{ provide: POSTS, useExisting: PostsService },
);
try {
const posts = await container.resolve(POSTS); // PostsService
posts.title();
} finally {
await container.dispose();
}Injection from another module requires exporting the alias from its owning
module and importing that module. The original class need not be exported, but
each exposed token needs its own alias/exports declaration. Manual
Symbol.for('my-blog/posts/v1') and resolve<PostsService>(token) can still use
the same alias. Tokens do not change scope. Keep request/actor/session providers
in per-request child scopes, dispose them in finally, and never put them into
root singletons or global caches. For multi-provider tokens, declare T as the
actual returned array type.
Scope Management
- Singleton (Default): Instance is created once and shared across the entire container.
- Request: Instance is created once per
createRequestScope()call. - Transient: A new instance is created every time it is resolved.
A singleton provider must not depend on a request-scoped provider. That mismatch throws ScopeMismatchError before any provider factory or constructor in the graph runs, and the check covers single, alias (useExisting), and multi-provider registrations. A singleton that injects a multi token fails the same way when any contribution under that token is request-scoped, so no partial contribution set is materialized first.
During disposal, each container tears down successfully materialized cached instances in reverse creation order across single-provider and multi-provider caches, so dependents are destroyed before their dependencies. Each container first recursively tears down live request-scope children it owns, so disposing a non-root request scope also closes nested request scopes before its own request cache. Root disposal then continues with root-owned singleton cleanup even if one or more child disposals fail. When multiple child/root disposals fail, dispose() reports an AggregateError so callers can inspect every shutdown failure without losing cleanup progress.
Starting dispose() is terminal for resolve(), register(), override(), and createRequestScope(). Concurrent callers share the active disposal attempt. If an onDestroy() hook fails, the container retains only that failed hook for a later explicit dispose() retry, preserving child-before-parent/root and reverse-creation ordering. Hooks that completed successfully are never run again, and disposal becomes idempotent after every retained hook succeeds.
Disposal retry ownership
Disposal retries follow five ownership rules:
- Calling public
child.dispose()directly detaches the request child from its parent graph after the active attempt settles, even when retainedonDestroy()hooks failed. - A retained child reference can call
dispose()again to retry only that child's failed hooks. Successful sibling hooks are not repeated. - A child first reached through parent or root disposal remains parent-tracked after failure, so a later parent or root
dispose()retries it before retained hooks in the parent or root. - Concurrent direct and parent callers share one active attempt. The caller that starts the shared attempt sets its direct or parent ownership, and later callers cannot change it.
- A later direct retry detaches a parent-retained child after settlement, even when that retry fails.
Executable evidence lives in packages/di/src/container-disposal-ownership.test.ts for graph ownership and packages/di/src/container-disposal-retry.test.ts for failed-hook ordering and idempotency.
Migrating disposal from 2.x to 3.x
In @fluojs/di 2.x, a failed container-managed onDestroy() hook was attempted once. In 3.x, a later explicit Container.dispose() call or application/application-context close() that reaches the same container retries only hooks that failed. Hooks that already completed successfully remain exactly-once. Before upgrading, make cleanup hooks that can fail safe to attempt again: preserve enough state to finish partial cleanup, tolerate resources that were already released, and surface a repeated failure to the shutdown caller.
Direct child.dispose() now detaches the request child from its parent after the attempt settles, including a failed attempt. Retain the child reference when the direct caller must inspect or retry that failure. A failure from parent- or root-started disposal remains owned by the parent hierarchy until cleanup succeeds or a later direct child attempt settles. When direct and parent callers overlap, the caller that starts the shared attempt owns those detach and retry semantics.
Provider Overrides
Use override(...providers) when a test or request-local boundary needs to replace existing registrations deliberately. Overrides replace the current provider set for each token, invalidate cached instances in the current container and already-materialized request-scope descendants, and dispose stale instances before the next replacement resolution continues. Multi-provider overrides replace the full multi-provider set for that token, so pass every replacement provider together; mixing single and multi replacements for the same token in one override call is rejected as ambiguous. An override call is atomic: the whole batch is validated before any registration or cache changes, so a rejected call leaves every provider, cached instance, and disposal ownership exactly as it was.
Container Construction Boundary
new Container() is the only supported public construction form, and it always creates a root container that owns its own singleton cache. Child request scopes are package-owned: parent linkage, the request-scope flag, and singleton-cache sharing use a private construction path reachable only through createRequestScope().
const root = new Container();
const requestScope = root.createRequestScope();Supplying constructor arguments is rejected. The emitted declaration accepts no assignable argument type, and at runtime a caller-supplied argument throws ContainerResolutionError rather than producing a container with borrowed cache ownership.
// Rejected: child-scope wiring is package-owned.
Reflect.construct(Container, [root]);Migrating container construction from 2.x to 3.x
In @fluojs/di 2.x, the emitted Container declaration exposed parent, requestScopeEnabled, and singletonCache constructor parameters even though caller-supplied child wiring was never a supported application workflow. In 3.x that surface is sealed: the constructor accepts no arguments, and passing any argument throws ContainerResolutionError.
Zero-argument new Container() and createRequestScope() are unchanged, so supported code needs no migration. If you constructed child containers directly, replace that call with parent.createRequestScope(), which supplies the same parent linkage, request-scope flag, and shared root singleton cache while keeping disposal ownership intact.
Executable evidence lives in packages/di/src/container-construction-boundary.test.ts.
A failed stale onDestroy() hook follows the same retained-retry contract as ordinary disposal. The next resolution on an observing container surfaces that failure once so the replacement can continue, and the failed instance stays retained by the container that scheduled its cleanup until a later explicit dispose() on that container invokes the hook again. Stale hooks that already completed successfully are never repeated.
Request Scoping
Isolated containers can be created to handle per-request state without polluting the root container.
const requestContainer = container.createRequestScope();
const scopedService = await requestContainer.resolve(RequestScopedService);Request-scope containers may resolve providers from their parent chain, but request-owned registrations must not introduce new singleton providers. Register singleton providers on the root container before creating request scopes. If a request scope needs local additions, declare them with scope: 'request' or use override() for an explicit request-local replacement. The same rule applies to multi providers: default-scope multi providers belong on the root container, while request-local multi providers must opt into request scope or be replaced through override().
Provider objects are validated at registration time: every object provider must include a string, symbol, or constructable class provide token and exactly one strategy (useClass, useValue, useFactory, or useExisting). Alias providers require the same valid token forms for useExisting. For class providers, an omitted or undefined inject value falls back to the useClass @Inject(...) metadata; any other explicit inject value must be an array containing valid tokens or well-formed ForwardRef.create(...) / Optional.create(...) wrappers. Value providers must omit inject; declaring it as an own property is rejected even when its value is undefined. Explicit scope values must be singleton, request, or transient. Invalid provider shapes throw InvalidProviderError before they can affect the container graph.
NestJS Scope and Optional Dependency Migration
NestJS @Injectable({ scope: Scope.REQUEST }) and @Injectable({ scope: Scope.TRANSIENT }) map to a fluo provider with @Scope('request') / @Scope('transient'), or an explicit provider scope: 'request' / scope: 'transient'. Singleton remains the default.
fluo does not implement NestJS scope bubbling. Resolve request-scoped providers from a createRequestScope() child container: root resolution throws RequestScopeResolutionError, and a singleton that depends on a request-scoped provider throws ScopeMismatchError.
NestJS @Optional() maps to Optional.create(Token) in a class-level @Inject(...) list or a provider inject array. Optional.create(...) is a token wrapper, not a decorator, and a missing registration resolves to undefined.
import { Inject, Scope } from '@fluojs/core';
import { Optional } from '@fluojs/di';
class AuditLogger {}
@Scope('request')
@Inject(Optional.create(AuditLogger))
class RequestAuditService {
constructor(private readonly auditLogger: AuditLogger | undefined) {}
}Circular Dependency Handling
The container automatically detects circular dependencies and throws a CircularDependencyError to prevent infinite loops. This includes direct (A→A), two-node (A→B→A), and deep (A→B→C→A) cycles.
Use ForwardRef.create() when a token is referenced before its declaration. It defers token lookup for declaration-order issues, but it does not make true constructor cycles resolvable; those cycles are still rejected with CircularDependencyError.
import { ForwardRef } from '@fluojs/di';
import { Inject } from '@fluojs/core';
@Inject(ForwardRef.create(() => ServiceB))
class ServiceA {
constructor(private readonly serviceB: ServiceB) {}
}
class ServiceB {
getStatus() {
return 'ready';
}
}ForwardRef.create(...) and Optional.create(...) are token wrappers used inside the class-level @Inject(...) token list or provider-level inject arrays. They are not decorators and do not attach to constructor parameters.
import { Optional } from '@fluojs/di';
import { Inject } from '@fluojs/core';
@Inject(Optional.create(AuditLogger))
class ServiceWithOptionalLogger {
constructor(private readonly auditLogger: AuditLogger | undefined) {}
}Testing and Mocking
Register the complete dependency graph first, then use override(...) with useValue to replace an existing provider with a mock or stub. register(...) adds new providers and rejects duplicate tokens; override(...) is the supported replacement API.
import { Inject } from '@fluojs/core';
import { Container } from '@fluojs/di';
import { expect, it, vi } from 'vitest';
class Database {
async query(): Promise<readonly string[]> {
return ['real row'];
}
}
@Inject(Database)
class DataService {
constructor(private readonly database: Database) {}
async load(): Promise<readonly string[]> {
return this.database.query();
}
}
it('uses a mock database', async () => {
const mockDb = { query: vi.fn().mockResolvedValue(['mock row']) };
const container = new Container().register(Database, DataService);
container.override({
provide: Database,
useValue: mockDb,
});
const service = await container.resolve(DataService);
await expect(service.load()).resolves.toEqual(['mock row']);
expect(mockDb.query).toHaveBeenCalledOnce();
});Internal Package Integrations
@fluojs/di/internal is a typed integration seam for first-party framework
packages. It resolves one ordered multi: true contribution through the
owning container, preserving the container's scope, cache, cycle, ordering,
and disposal semantics. Application code must use Container.resolve(...);
contribution indexes are not part of the root Container API.
Compatible same-realm @fluojs/di copies can resolve an ordered contribution
only through the exact container that registered it. The resolver map shares
container-bound entries, not containers, caches, request scopes, or application
graphs.
Troubleshooting
CircularDependencyError
Thrown when the container detects a cycle in the dependency graph. Check your constructor injections and remove the cycle by extracting shared state, introducing a mediator, or changing the lifetime boundary. ForwardRef.create() only defers token lookup for declaration-order issues; it does not break true constructor cycles.
Token Not Found
Ensure all required providers are registered in the container. If you use createRequestScope(), the child container can resolve tokens from the parent, but not vice versa.
Public API
ForwardRef.create(fn) and Optional.create(token) own wrapper creation and return
frozen plain records, preserving resolver and token identity without resolving them.
Metadata and provider registration still snapshot wrapper records. Both Core and DI
export the shared ForwardRefToken<T> and OptionalInjectToken<T> types.
The removed creation functions, runtime scope namespace, and old wrapper type names
have no compatibility aliases. Follow the
Core and DI migration guide.
Optional.create is a DI token factory, not the unrelated HTTP Optional field decorator.
| Surface | Kind | Description |
|---|---|---|
| Container | Root export | The main DI container class. new Container() takes no arguments and creates a root container; child request scopes are package-owned and created with createRequestScope(). Supplying constructor arguments throws ContainerResolutionError. |
| container.register(...providers) | Container instance method | Registers one or more providers. |
| container.override(...providers) | Container instance method | Replaces existing providers atomically per call, invalidates cached instances, and ensures stale instance disposal settles before the next replacement resolution continues. |
| container.resolve<T>(token) | Container instance method | Asynchronously resolves a token to an instance. |
| container.inspectResolutionState() | Container instance method | Exposes the supported framework-owned container introspection seam for testing/tooling helpers that must preserve cache ownership through snapshot read-only map views, frozen provider records, and controlled cache adoption. Prefer has(...) and resolve(...) for application code. |
| container.createRequestScope() | Container instance method | Creates a child container for request-scoped dependencies. This is the only supported path to parent-linked, request-scope-enabled containers that share the root singleton cache. |
| container.has(token) | Container instance method | Checks if a token is registered in the container or its parents. |
| container.hasRequestScopedDependency(token) | Container instance method | Checks whether resolving a token may require a request-scope container because its provider graph contains request-scoped dependencies or is cyclic. |
| container.dispose() | Container instance method | Disposes request children before parent/root caches, shares an active attempt, and retries only failed onDestroy() hooks on a later explicit call. |
| ForwardRef.create(fn) | Returns a token wrapper that defers lookup for declaration-order issues; it does not make constructor dependency cycles resolvable. |
| isForwardRef(value) | Type guard for values produced by ForwardRef.create(...); useful when integrating custom provider tooling with DI token wrappers. |
| Optional.create(token) | Returns a token wrapper that marks one dependency as optional; missing optional dependencies resolve to undefined. |
| isOptionalToken(value) | Type guard for values produced by Optional.create(...); useful when inspecting provider-level inject arrays. |
| Scope | Type-only union of 'singleton', 'request', and 'transient'. Import the decorator from @fluojs/core. |
| Provider types | Provider, ClassProvider, FactoryProvider, ValueProvider, and ExistingProvider describe the public registration shapes accepted by register(...) and override(...). |
| Token wrapper types | ForwardRefToken and OptionalInjectToken describe the wrapper values returned by ForwardRef.create(...) and Optional.create(...). |
| Container helper types | ClassType, Disposable, and RequestScopeContainer support typed provider declarations, teardown hooks, and request-scope helper boundaries. |
| Container introspection helper types | ContainerResolutionState, ContainerResolutionCacheOwner, and ContainerFactoryResolutionState describe the read-only graph/cache views and controlled cache adoption helpers returned by inspectResolutionState(). |
| FactoryResolutionKind | Root export | Classifies whether a factory provider returned synchronously (sync) or through a promise (async) for container diagnostics and introspection. |
| NormalizedProvider | Compatibility-only public type for the container's validated provider record shape. Prefer authoring providers with Provider or the specific provider interfaces; the container owns normalized record construction. |
| @fluojs/di/internal | Package-integration seam exposing validateProviderInputs(...) so sibling fluo packages can apply the container's canonical provider validation before their own traversal. Application code should continue to register providers through Container. |
| DiErrorContext | Structured context attached to DI errors so logs and tests can inspect tokens, scopes, modules, dependency chains, and hints. |
| Error classes | InvalidProviderError, ContainerResolutionError, RequestScopeResolutionError, ScopeMismatchError, CircularDependencyError, DuplicateProviderError. Use isDiError(...) at package boundaries; it validates the versioned DI owner contract and required common fields across compatible duplicate copies. |
Resolving a multi-provider token returns an array of resolved values in registration order.
Related Packages
@fluojs/core: Defines the@Inject()and@Scope()decorators used to annotate classes.@fluojs/runtime: Handles automatic registration of providers during application bootstrap.@fluojs/http: Creates a request scope for every incoming HTTP request.
Example Sources
packages/di/src/container.tspackages/di/src/container.test.tsexamples/minimal/src/app.ts
Compatible framework service copies
Explicitly designated framework service classes resolve across compatible same-realm package copies through ordinary class injection. Unmarked classes, named tokens, scopes, overrides, and module visibility retain their existing semantics.
