@fluojs/di
v2.0.0
Published
Minimal token-based dependency injection container powering every Fluo application.
Maintainers
Readme
@fluojs/di
Minimal token-based dependency injection container powering every fluo application.
Table of Contents
- Installation
- When to Use
- Quick Start
- Key Capabilities
- Circular Dependency Handling
- Testing and Mocking
- 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.
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.
During disposal, 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.
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.
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'/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 non-null provide token and exactly one strategy (useClass, useValue, useFactory, or 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(...) / optional(...) wrappers. Explicit scope values must be singleton, request, or transient. Invalid provider shapes throw InvalidProviderError before they can affect the container graph.
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() 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(() => ServiceB))
class ServiceA {
constructor(private readonly serviceB: ServiceB) {}
}
class ServiceB {
getStatus() {
return 'ready';
}
}forwardRef(...) and optional(...) 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(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();
});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() 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
| Surface | Kind | Description |
|---|---|---|
| Container | Root export | The main DI container class. |
| container.register(...providers) | Container instance method | Registers one or more providers. |
| container.override(...providers) | Container instance method | Replaces existing providers, 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. |
| 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 and root-owned singleton instances. |
| forwardRef(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(...); useful when integrating custom provider tooling with DI token wrappers. |
| optional(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(...); useful when inspecting provider-level inject arrays. |
| Scope | Exposes DEFAULT, REQUEST, and TRANSIENT scope constants. |
| Provider types | Provider, ClassProvider, FactoryProvider, ValueProvider, and ExistingProvider describe the public registration shapes accepted by register(...) and override(...). |
| Token wrapper types | ForwardRefFn and OptionalToken describe the wrapper values returned by forwardRef(...) and optional(...). |
| 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(). |
| 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. |
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
