@noego/ioc
v0.9.0
Published
A self contained IoC container for Node.js
Downloads
924
Readme
@noego/ioc
As a TypeScript application grows, object creation becomes part of the architecture.
At first, wiring classes by hand is simple. Over time, the same project needs shared services, request-scoped state, runtime configuration, multiple implementations, and tests that can replace collaborators without reaching into internals. Those decisions start appearing in constructors, factories, globals, and test setup.
That is the problem @noego/ioc is built around: keeping dependency management explicit, consistent, and automatic as the codebase scales.
Classes declare what they need. The container resolves the graph, manages singleton/scoped/transient lifetimes, carries runtime values, supports implementation swapping, and gives tests the same dependency boundary production code uses.
Application code stays focused on behavior while construction, reuse, and dependency boundaries are handled in one place.
Composition and resource ownership
Existing createContainer, registerClass, registerFunction, get, instance, and decorator calls remain available. Apply replacements before resolving the affected graph:
- Replacing a resolved or in-flight token throws
LateRegistrationError. Singleton protection applies across its root hierarchy. A fresh child can replace an unresolved scoped/transient binding; it cannot replace an already-created shared singleton. New unrelated tokens and decorator auto-resolution remain supported. - Configure instance decorators before materialization in the hierarchy.
setInstanceDecorator(fn)replaces the pipeline;addInstanceDecorator(fn)appends an outer layer without losing earlier layers. Late configuration throwsLateInstanceDecoratorError. Decorators must preserve return shape and forward context-owner symbols. - Registered resources are managed by default, including transient resources. For caller-owned resources, pass
owned: false:
const root = createContainer();
root.registerFunction("externalConnection", () => existingConnection, {
loadAs: LoadAs.Singleton,
owned: false,
});
// existingConnection remains the caller's responsibility.Decorated classes can declare the same opt-out: @Component({ scope: LoadAs.Transient, owned: false }). Use it for instances whose owner disposes them (for example a per-element runtime disposed on unmount); registration options (registerClass(..., { owned })) take precedence. Transients without a cleanup method (dispose, Symbol.dispose, Symbol.asyncDispose, destroy) are never retained: there is nothing to dispose.
Proxies
Every resolved instance is wrapped in a context proxy by default, so method calls and getter reads made outside any execution context run inside the owning scope. For containers that do not need that, createContainer({ proxy: false }) returns raw instances for the whole hierarchy (scopes created with extend() inherit it): no context proxy and no tracing proxy. Method calls then cost the same as on any object; code that needs a scope active enters it with ExecutionContext.run(scope, fn).
const root = createContainer({ proxy: false });Always await dispose(). It closes child scopes before root resources, attempts all cleanup, and aggregates failures in ScopeDisposalError.failures. Concurrent and repeated calls share the same completion (including a previous failure). Explicitly disposing a child does not close root singletons. A resource's own cleanup must not call its container's (or an ancestor's) dispose(); that teardown cycle is reported as a cleanup failure.
Failed resolution rolls back resources created for that graph, preserving pre-existing or successfully shared resources. If rollback itself fails, ResolutionRollbackError retains primaryError, cause, and cleanupFailures. Synchronous construction errors remain synchronous: if cleanup must continue asynchronously, dispose() waits for it and reports deferred cleanup failures. Extensible thrown objects also expose a rollback promise resolving to those failures; use dispose() for the general case, including primitive or frozen errors.
A factory remains responsible for allocations it makes internally and never returns to the container. Disposal waits for owned pending construction; it cannot force an arbitrary never-settling user promise to complete. This does not add a generic task scheduler or host shutdown timeout.
Execution context capabilities
Version 0.8.0 requires Node.js 20.16.0 or newer. Node ESM execution context propagation uses process.getBuiltinModule; browser runtimes retain the explicitly synchronous fallback. The lifecycle rules above apply to 0.8.0.
The existing ExecutionContext.run(scope, callback) and current() APIs retain their call and return shapes. Hosts that require propagation across awaits can check the actual shared channel before starting:
import { ExecutionContext } from "@noego/ioc";
ExecutionContext.requireAsyncPropagation(); // throws unless capability is known async
const { propagation } = ExecutionContext.capabilities(); // "async" | "sync" | "unknown"A synchronous fallback reports "sync"; an older shared channel without capability metadata reports "unknown". Neither is silently advertised as asynchronous just because a later-loaded package copy can access AsyncLocalStorage. Compatible copies reuse the first channel; it is not replaced while other copies may be executing. If async propagation is required, initialize with an async-capable copy in a fresh process/realm.
The assertion is opt-in: run still supports synchronous browser ownership and does not change promises into synchronous values or vice versa. In a sync-only host, explicitly re-enter the appropriate scope around later callbacks; do not assume ownership survives await. Node tests and simulated fallback tests do not qualify actual Worker or browser runtime integration.
Installation
npm install @noego/ioc
# or
yarn add @noego/iocIf you want to use decorators, also install reflect-metadata:
npm install reflect-metadata
# or
yarn add reflect-metadataAnd configure TypeScript for decorator support in tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "ESNext",
"moduleResolution": "node",
"target": "ESNext"
}
}Problems This Solves
@noego/ioc is useful when construction rules become part of the application design instead of a few new calls.
Resolve a Whole Graph From One Entry Point
Ask the container for the controller, job, or service you want to run. Its dependencies are resolved from constructor annotations.
flowchart LR
App["App code"] --> Controller["UserController"]
Controller --> Service["UserService"]
Service --> Repo["UserRepository"]
Repo --> Db["DatabaseService"]@Component({ scope: LoadAs.Singleton })
class DatabaseService {}
@Component({ scope: LoadAs.Singleton })
class UserRepository {
constructor(@Inject(DatabaseService) private database: DatabaseService) {}
}
@Component({ scope: LoadAs.Singleton })
class UserService {
constructor(@Inject(UserRepository) private users: UserRepository) {}
}
const container = createContainer();
const service = await container.instance(UserService);Keep Request State Scoped
Use container.extend() when a request, job, page, or test needs isolated state while still sharing root singletons.
flowchart TD
Root["Root container<br/>singletons"] --> A["Request scope A"]
Root --> B["Request scope B"]
A --> StateA["RequestContext<br/>user-1"]
B --> StateB["RequestContext<br/>user-2"]const USER_ID = Parameter.create("userId");
@Component({ scope: LoadAs.Scoped })
class RequestContext {
constructor(@Inject(USER_ID) readonly userId: string) {}
}
const root = createContainer();
const requestA = root.extend();
const contextA = await requestA.instance(RequestContext, [
USER_ID.value("user-1"),
]);
const requestB = root.extend();
const contextB = await requestB.instance(RequestContext, [
USER_ID.value("user-2"),
]);Swap a Boundary in Tests
Use an abstract class as the injectable token. Production registers the real implementation; tests register a fake before resolving the class under test.
flowchart LR
Service["CheckoutService"] --> Gateway["PaymentGateway<br/>abstract token"]
Gateway --> Stripe["StripePaymentGateway<br/>production"]
Gateway -. "test registration" .-> Fake["FakePaymentGateway"]abstract class PaymentGateway {
abstract charge(amount: number): Promise<string>;
}
@Component({ scope: LoadAs.Singleton })
class CheckoutService {
constructor(@Inject(PaymentGateway) private payments: PaymentGateway) {}
}
class FakePaymentGateway extends PaymentGateway {
async charge(amount: number): Promise<string> {
return "fake-payment-id";
}
}
const testContainer = createContainer();
testContainer.registerFunction(
PaymentGateway,
() => new FakePaymentGateway(),
{ loadAs: LoadAs.Singleton },
);
const checkout = await testContainer.instance(CheckoutService);Change Behavior Per Scope
When runtime data decides which class should run, inject SCOPED_CONTAINER. The method can inspect the current environment, choose an implementation class, resolve it from the active scope, and call it.
flowchart TD
Scope["Scoped container"] --> FileOpener["FileOpener"]
FileOpener --> Os["OsService"]
FileOpener --> Container["SCOPED_CONTAINER"]
Os --> Decision{"platform?"}
Decision -->|darwin| Mac["MacOpenFileRuntime"]
Decision -->|win32| Windows["WindowsOpenFileRuntime"]
Container --> Mac
Container --> Windowsimport createContainer, {
Component,
Inject,
type IContainer,
LoadAs,
SCOPED_CONTAINER,
} from "@noego/ioc";
abstract class OpenFileRuntime {
abstract open(path: string): Promise<void>;
}
@Component({ scope: LoadAs.Singleton })
class OsService {
platform(): NodeJS.Platform {
return process.platform;
}
}
@Component({ scope: LoadAs.Singleton })
class MacOpenFileRuntime extends OpenFileRuntime {
async open(path: string): Promise<void> {
await run("open", [path]);
}
}
@Component({ scope: LoadAs.Singleton })
class WindowsOpenFileRuntime extends OpenFileRuntime {
async open(path: string): Promise<void> {
await run("cmd", ["/c", "start", path]);
}
}
@Component({ scope: LoadAs.Scoped })
class FileOpener {
constructor(
@Inject(SCOPED_CONTAINER) private container: IContainer,
@Inject(OsService) private os: OsService,
) {}
async open(path: string): Promise<void> {
const Runtime =
this.os.platform() === "win32"
? WindowsOpenFileRuntime
: MacOpenFileRuntime;
const runtime = await this.container.instance(Runtime);
await runtime.open(path);
}
}
const root = createContainer();
const scope = root.extend();
const opener = await scope.instance(FileOpener);
await opener.open("report.pdf");Quick Start
After installing and enabling decorators, create an entry file such as index.ts:
import 'reflect-metadata';
import createContainer, { Component, Inject, LoadAs } from '@noego/ioc';
@Component({ scope: LoadAs.Singleton })
class DatabaseService {}
@Component({ scope: LoadAs.Singleton })
class ExampleService {
constructor(@Inject(DatabaseService) private db: DatabaseService) {}
}
async function bootstrap() {
const container = createContainer();
const service = await container.instance(ExampleService);
console.log('Service instance:', service);
}
bootstrap();Run it with your TypeScript runner:
npx ts-node index.tsFeatures
- Dual Module Support: Compatible with CommonJS and ES Modules
- TypeScript & Typings: Built in TypeScript with bundled declaration files
- Multiple Lifetime Scopes: Support for Singleton, Transient, and Scoped dependencies
- Class & Function Registration: Register both classes and functions as dependencies
- Parameter Injection: Inject parameter values at resolution time
- Container Extension: Create child containers that inherit parent registrations
- Container Self-Injection: Use
SCOPED_CONTAINERfor rare runtime implementation selection - Decorator Support: Use
@Componentand@Injectdecorators for clean, declarative DI - Sync-First Resolution: Synchronous resolution when all dependencies are sync, with async fallback when needed
- Type-Safe Sync Mode: Generic
Syncparameter ongetandinstancefor compile-time type narrowing - Method Call Tracing: Automatic tracing of method calls with performance metrics and dependency hierarchies
- Trace Analytics: Export and analyze traces, track statistics, and monitor dependency interactions
- Lightweight: Small footprint with minimal external dependencies
Project-Learned Usage Patterns
The container supports a broad API surface, including manual param arrays, reflected constructor types, and @Provider. In larger projects that use this package heavily, the most important lesson is that IoC exists to make behavior testable through explicit seams. If a feature works manually but cannot be tested by resolving a class from a fresh container with controlled collaborators, the design is incomplete.
The safer production convention is narrower. These are conventions, not hard library limits. They come from using the container in the Demo Assistant desktop app and are the recommended reading for production code.
Stateless Singleton Operations
A stateless singleton is a class with no mutable runtime fields. It receives all changing data as method arguments and returns a result. This is where helper functions, calculations, projectors, resolvers, readers, and formatters should go once they matter enough to inject and test.
Use LoadAs.Singleton by default for these classes. They are cheap to share, easy to swap in tests, and keep repeated helper logic out of stateful services.
@Component({ scope: LoadAs.Singleton })
class PriceCalculator {
total(items: Array<{ quantity: number; unitPrice: number }>): number {
return items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0,
);
}
}
@Component({ scope: LoadAs.Singleton })
class CheckoutService {
constructor(@Inject(PriceCalculator) private prices: PriceCalculator) {}
quote(items: Array<{ quantity: number; unitPrice: number }>): number {
return this.prices.total(items);
}
}Because CheckoutService receives PriceCalculator through the container, a test can swap the calculation without mocking the module:
class FixedPriceCalculator extends PriceCalculator {
total(): number {
return 100;
}
}
const container = createContainer();
container.registerFunction(
PriceCalculator,
() => new FixedPriceCalculator(),
{ loadAs: LoadAs.Singleton },
);
const checkout = await container.instance(CheckoutService);Scoped State for Request, Page, or User Data
Use LoadAs.Scoped for mutable state tied to one request, page, user, conversation, or test case. Resolve scoped work through container.extend() so every class in that scope sees the same scoped state instance.
@Component({ scope: LoadAs.Scoped })
class RequestState {
userId: string | null = null;
}
@Component({ scope: LoadAs.Scoped })
class RequestUserWriter {
constructor(@Inject(RequestState) private state: RequestState) {}
setUser(userId: string): void {
this.state.userId = userId;
}
}
@Component({ scope: LoadAs.Scoped })
class RequestUserReader {
constructor(@Inject(RequestState) private state: RequestState) {}
currentUser(): string | null {
return this.state.userId;
}
}
const requestContainer = container.extend();
const writer = await requestContainer.instance(RequestUserWriter);
const reader = await requestContainer.instance(RequestUserReader);Do not thread snapshots through constructors, mirror scoped state into multiple services, or inject a stateful facade back into extracted domain classes. Put the shared mutable fact in one scoped state class and inject that state where needed.
Transient Instances for Unique Objects
Use LoadAs.Transient rarely. It is for objects where every resolution must produce a fresh instance, such as an isolated builder, cursor, or accumulator.
@Component({ scope: LoadAs.Transient })
class ReportBuilder {
private sections: string[] = [];
addSection(text: string): void {
this.sections.push(text);
}
build(): string {
return this.sections.join("\n\n");
}
}
const first = await container.instance(ReportBuilder);
const second = await container.instance(ReportBuilder);If the class has no mutable fields, prefer LoadAs.Singleton. If the class has mutable state that should be shared within one request or page, prefer LoadAs.Scoped.
Explicit Constructor Injection
Add @Inject(...) to every constructor parameter, including concrete classes. Reflected constructor metadata is a fallback, not a project convention.
@Component({ scope: LoadAs.Singleton })
class UserService {
constructor(
@Inject(UserRepository) private users: UserRepository,
@Inject(PriceCalculator) private prices: PriceCalculator,
) {}
}Prefer direct injection when a dependency has one implementation. Do not introduce a provider or runtime selector just to wrap container.instance(SomeClass).
Abstract Contracts for Swappable Boundaries
Use abstract classes, not TypeScript interfaces, for injectable runtime contracts. Interfaces are erased at runtime, while abstract classes can be used as container tokens.
abstract class EmailSender {
abstract send(to: string, body: string): Promise<void>;
}
@Component({ scope: LoadAs.Singleton })
class SmtpEmailSender extends EmailSender {
async send(to: string, body: string): Promise<void> {
// Send through SMTP.
}
}
@Component({ scope: LoadAs.Singleton })
class InviteService {
constructor(@Inject(EmailSender) private email: EmailSender) {}
}Tests can register a fake implementation before resolving the class under test:
class FakeEmailSender extends EmailSender {
sent: Array<{ to: string; body: string }> = [];
async send(to: string, body: string): Promise<void> {
this.sent.push({ to, body });
}
}
container.registerFunction(
EmailSender,
() => new FakeEmailSender(),
{ loadAs: LoadAs.Singleton },
);Use co-located .mock.ts files for these test registrations. Do not use vi.mock() for internal IoC services; it bypasses the same seam production code uses.
Runtime Parameters for Scoped Values
Use Parameter.create(...) when a class needs runtime values such as tenant IDs, user IDs, or config strings alongside injected dependencies. The value can be passed at the top-level resolution and consumed by a dependency deeper in the graph.
const USER_ID = Parameter.create("userId");
const USER_ROLE = Parameter.create("userRole");
@Component({ scope: LoadAs.Scoped })
class CurrentUser {
constructor(
@Inject(USER_ID) readonly userId: string,
@Inject(USER_ROLE) readonly role: "admin" | "member",
) {}
}
@Component({ scope: LoadAs.Singleton })
class UserRepository {
async findUser(userId: string): Promise<User> {
// Query the database.
// ...
}
}
@Component({ scope: LoadAs.Scoped })
class PermissionReader {
constructor(
@Inject(CurrentUser) private user: CurrentUser,
@Inject(UserRepository) private users: UserRepository,
) {}
async canEditDocument(): Promise<boolean> {
const owner = await this.users.findUser(this.user.userId);
return this.user.role === "admin" || owner.id === this.user.userId;
}
}
@Component({ scope: LoadAs.Scoped })
class DocumentController {
constructor(@Inject(PermissionReader) private permissions: PermissionReader) {}
async canEdit(): Promise<boolean> {
return this.permissions.canEditDocument();
}
}
const requestContainer = container.extend();
const controller = await requestContainer.instance(DocumentController, [
USER_ID.value("user-123"),
USER_ROLE.value("admin"),
]);DocumentController does not inject USER_ID or USER_ROLE directly, but both values still reach CurrentUser through PermissionReader. If the parameter value changes per request, keep the parameter-consuming class Scoped or Transient; a Singleton would keep the first value it was constructed with.
Scoped Container for Runtime Class Selection
Use SCOPED_CONTAINER only when runtime data genuinely selects among multiple implementation classes. Select the implementation class token, then resolve that class through the active scoped container.
@Component({ scope: LoadAs.Scoped })
class RuntimeLauncher {
constructor(@Inject(SCOPED_CONTAINER) private container: IContainer) {}
async launch(runtime: "local" | "remote"): Promise<void> {
const RuntimeClass = runtime === "local" ? LocalRuntime : RemoteRuntime;
const instance = await this.container.instance(RuntimeClass);
await instance.start();
}
}Avoid @Provider in application code unless you are maintaining legacy code that already uses it. A provider that only wraps container.instance(SomeClass) is usually unnecessary factory indirection.
Resolve Through the Container
Do not manually instantiate IoC classes with new; resolve them through the container so dependencies, lifetimes, tracing, and overrides all apply.
// Good
const service = await container.instance(CheckoutService);
// Avoid
const service = new CheckoutService(new PriceCalculator());Testability Defines Done
Testability is the reason for the pattern, not a follow-up task. New code should have an obvious test seam before it is considered finished.
Good IoC tests:
- Create a fresh
new Container()orcreateContainer()per test. - Register fakes before resolving the class under test.
- Resolve the real class through the container.
- Drive behavior through public methods or controller/input methods.
- Assert at the nearest owned boundary: domain method, writer, repository, adapter, validator, or controller.
Bad IoC tests:
- Import the app's shared container.
- Use
vi.mock()for internal services. - Reach into private fields,
@internalgetters, or test-only backdoors. - Manually instantiate a container-managed class and hand-wire its dependencies.
- Use an end-to-end test to cover behavior that could be tested through a smaller owned seam.
Example:
// payment_processor.mock.ts
import type { IContainer } from "@noego/ioc";
import { LoadAs } from "@noego/ioc";
import { PaymentProcessor } from "./payment_processor";
class MockPaymentProcessor extends PaymentProcessor {
async charge(amount: number): Promise<string> {
if (amount <= 0) {
throw new Error("amount must be positive");
}
return "mock-payment-id";
}
}
export function mockPaymentProcessor(container: IContainer): void {
container.registerFunction(PaymentProcessor, () => new MockPaymentProcessor(), {
loadAs: LoadAs.Singleton,
});
}
// order_service.test.ts
it("charges through the configured processor", async () => {
const container = createContainer();
mockPaymentProcessor(container);
const service = await container.instance(OrderService);
await expect(service.placeOrder({ amount: 25 })).resolves.toEqual({
status: "paid",
paymentId: "mock-payment-id",
});
});Boundary fakes should enforce the same validation as real boundary implementations. A fake that accepts illegal payloads gives false confidence.
ESM vs CJS imports
- Modern Node (>=14.13, >=16 recommended) and bundlers that honor
package.exportscan use either:import { createContainer } from '@noego/ioc'import createContainer from '@noego/ioc'
- If you see “does not provide an export named 'createContainer'”, your toolchain likely resolved the CommonJS build. Use this interop-safe pattern:
import pkg from '@noego/ioc'; const { createContainer } = pkg;- Or upgrade Node to a version that supports conditional exports.
Usage
Manual Registration
import createContainer from "@noego/ioc";
const container = createContainer();
container.registerClass(Database);
container.registerClass(UserRepository, { param: [Database] });
container.registerClass(UserService, { param: [UserRepository] });
const userService = await container.instance(UserService);Lifetime Scopes
The container supports three different lifetime scopes:
- Transient: A new instance is created every time the dependency is resolved
- Singleton: Only one instance is created and reused throughout the application
- Scoped: A single instance is created per container scope
import { Component, Inject, LoadAs } from "@noego/ioc";
@Component({ scope: LoadAs.Singleton })
class Database {}
@Component({ scope: LoadAs.Scoped })
class RequestContext {
constructor(@Inject(Database) private db: Database) {}
}If you use manual registration, loadAs is still available and overrides the decorator scope. In application code, prefer the decorator scope so the class owns its lifetime.
Singleton Dependency Rule
Starting in 0.4.0, a singleton dependency graph may contain singleton dependencies only. A singleton may not constructor-inject a Scoped or Transient class/factory, either directly or transitively. The runtime throws CaptiveLifetimeError, and compileContainerGraph() reports a captive-lifetime error with the full dependency path.
@Component({ scope: LoadAs.Singleton })
class Database {}
@Component({ scope: LoadAs.Singleton })
class UserRepository {
constructor(@Inject(Database) private db: Database) {} // valid
}
@Component({ scope: LoadAs.Scoped })
class RequestContext {}
@Component({ scope: LoadAs.Singleton })
class InvalidService {
constructor(@Inject(RequestContext) private request: RequestContext) {} // error
}This rule prevents captive dependencies. A transient captured by a singleton would silently become a de-facto singleton, while a scoped dependency would be retained beyond the request/page/test scope that created it. If a singleton needs request-specific data, pass that data to a method, move the consumer to Scoped, or redesign the dependency boundary rather than capturing a shorter-lived object.
The rule is recursive. Singleton -> Singleton -> Transient and Singleton -> Transient -> Scoped are invalid for the same reason as a direct capture.
Parameter Injection
You can inject parameter values at resolution time:
import { Parameter } from "@noego/ioc";
class User {
constructor(public id: number, public name: string) {}
}
// Create parameters
const USER_ID = Parameter.create("userId");
const USER_NAME = Parameter.create("userName");
// Register with parameters
container.registerClass(User, { param: [USER_ID, USER_NAME] });
// Resolve with parameter values
async function createUser() {
const user = await container.instance(User, [
USER_ID.value(1),
USER_NAME.value("John")
]);
console.log(user.id, user.name); // 1, "John"
}Function Registration
You can also register functions as dependencies:
function createLogger(prefix: string) {
return {
log: (message: string) => console.log(`${prefix}: ${message}`)
};
}
const PREFIX = Parameter.create("prefix");
// Register function
container.registerFunction("logger", createLogger, {
param: [PREFIX]
});
// Resolve function
async function useLogger() {
const logger = await container.get("logger", [PREFIX.value("APP")]);
logger.log("Application started"); // "APP: Application started"
}Using Decorators
After decorator support is configured, use @Component and @Inject to make class dependencies explicit.
Component Decorator
Use @Component to mark a class as a component with an optional scope:
import { Component, Inject, LoadAs } from '@noego/ioc';
@Component({ scope: LoadAs.Singleton })
class UserService {
// ...
}
@Component({ scope: LoadAs.Singleton })
class DatabaseService {
// ...
}
@Component({ scope: LoadAs.Scoped })
class RequestContext {
// ...
}Inject Decorator
Use @Inject to specify the dependency token for each constructor parameter. This is required by project convention even when the parameter type is concrete:
import { Component, Inject, LoadAs } from '@noego/ioc';
// Use an abstract class for injectable contracts. Interfaces are erased at runtime.
abstract class Logger {
abstract log(message: string): void;
}
@Component({ scope: LoadAs.Singleton })
class ConsoleLogger extends Logger {
log(message: string) {
console.log(message);
}
}
@Component({ scope: LoadAs.Singleton })
class DatabaseService {}
@Component({ scope: LoadAs.Singleton })
class UserService {
constructor(
@Inject(Logger) private logger: Logger,
@Inject(DatabaseService) private database: DatabaseService,
) {}
createUser() {
this.logger.log('Creating user...');
// ...
}
}
// Register
const container = createContainer();
container.registerFunction(Logger, () => container.instance(ConsoleLogger), {
loadAs: LoadAs.Singleton,
});
// Resolve
const service = await container.instance(UserService);Override Priority
Manual registration options take precedence over decorators:
- Manually defined parameters in
registerClass({ param: [...] })override constructor parameter types and@Injectannotations. - Manually defined scope in
registerClass({ loadAs: ... })overrides@Component({ scope: ... }).
This allows you to change behavior at registration time without modifying the decorated class.
Sync Resolution
By default, get and instance return Promise<T> | T. When your entire dependency graph is synchronous (no async factory functions), the container resolves synchronously. If you know at the call site that resolution will be sync, pass true as the second generic parameter to get a narrowed T return type:
// Default — returns Promise<T> | T
const service = container.instance(UserService);
// When you know the dependency graph is sync — returns T
const service = container.instance<UserService, true>(UserService);
const logger = container.get<Logger, true>(Logger);This is purely a compile-time hint — no runtime behavior changes. If a dependency turns out to be async at runtime, you'll get a Promise back regardless of the type annotation.
Extending Containers
You can create a child container that inherits all the registrations from the parent but allows overriding:
// Create parent container
const parentContainer = createContainer();
parentContainer.registerClass(Database);
// Create child container
const childContainer = parentContainer.extend();
// Override in child container
childContainer.registerClass(Database, { /* different configuration */ });
// Parent container still uses the original registration
// Child container uses the new registrationRuntime Selection with SCOPED_CONTAINER
Prefer direct constructor injection for normal dependencies. Use SCOPED_CONTAINER only when runtime data genuinely selects among multiple implementation classes. The selected value should be the implementation class token itself, not a string mode that later gets switched back into a class.
Use this for plugin systems, multi-tenancy, user-selected implementations, and request-specific routing. The earlier FileOpener example shows the pattern: inject the scoped container, choose an implementation class from runtime data, resolve that class, then call it. Do not introduce a factory/provider class when direct injection or a thin class-token selection is enough.
The @Provider decorator still exists for compatibility. In project code, treat it as legacy or exceptional. A provider whose only job is to call container.instance(SomeClass) adds indirection without adding a boundary.
Method Call Tracing and Monitoring
The container supports automatic tracing of method calls on resolved instances. This is useful for debugging, monitoring, and understanding dependency interactions in your application.
Enabling Tracing
const container = createContainer();
// Enable tracing
container.setTracingEnabled(true);
// Optional: Set trace retention (default is 5 minutes)
container.setTraceRetentionMinutes(10);
// Register your classes
container.registerClass(DatabaseService);
container.registerClass(UserService);
// When instances are resolved, method calls are automatically traced
const service = await container.get(UserService);
service.getUsers(); // This call will be tracedRetrieving Traces
// Get recent traces within retention window
const traces = await container.getTraces();
console.log(traces);
// Get all traces ever recorded
const allTraces = await container.getAllTraces();
// Get trace statistics
const stats = await container.getTraceStatistics();
console.log(`Total method calls traced: ${stats.totalTraces}`);
console.log(`Total proxies created: ${stats.totalProxies}`);How Tracing Works
When tracing is enabled:
- Automatic Wrapping: Each resolved instance is wrapped in a JavaScript Proxy that intercepts method calls
- Call Recording: Every method call is recorded with:
- Method name and parameters
- Return value or error (if thrown)
- Execution duration in milliseconds
- Parent-child dependency relationships
- Zero Overhead When Disabled: When tracing is disabled, instances are not wrapped and there's no performance impact
- Database Storage: Traces are stored in-memory using sql.js (pure JavaScript SQLite)
- Automatic Cleanup: Old traces are automatically cleaned up based on retention settings
Trace Statistics
The trace statistics provide insights into your application's dependency interactions:
const stats = await container.getTraceStatistics();
// Example output:
// {
// totalTraces: 42, // Total method calls recorded
// totalProxies: 5, // Total unique instances traced
// proxiesByClass: {
// UserService: 1,
// DatabaseService: 1,
// UserRepository: 1
// },
// methodCallsByProxy: {
// 1: 12, // Proxy 1 had 12 method calls
// 2: 8, // Proxy 2 had 8 method calls
// // ...
// }
// }Exporting and Analyzing Traces
// Export traces to JSON for analysis
const exported = await TraceLoggerModule.exportTracesToJson();
// or use container method
await container.exportTraces('./traces.json');
// Clear traces
await container.clearTraces();Tracing with Dependency Hierarchies
When an instance depends on other instances, the tracing system records the parent-child relationships:
@Component({ scope: LoadAs.Singleton })
class Database {
query() { return 'data'; }
}
@Component({ scope: LoadAs.Singleton })
class UserService {
constructor(@Inject(Database) private db: Database) {}
getUsers() { return this.db.query(); }
}
const container = createContainer();
container.setTracingEnabled(true);
const service = await container.get(UserService);
await service.getUsers();
// Traces will show the call hierarchy:
// UserService.getUsers() -> Database.query()API Reference
Container
createContainer(): Creates a new IoC containerregisterClass<T>(classDefinition, options?): Register a classregisterFunction(label, function, options?): Register a functioninstance<T, Sync>(classDefinition, params?): Resolve a class instance. PassSync = truefor sync type narrowingget<T, Sync>(label, params?): Resolve a dependency by key. PassSync = truefor sync type narrowingextend(): Create a child containersetTracingEnabled(enabled: boolean): Enable/disable method call tracingisTracingEnabled(): boolean: Check if tracing is enabledsetTraceRetentionMinutes(minutes: number): Set trace retention windowgetTraces(retentionMinutes?: number): Promise<TraceRecord[]>: Get recent tracesgetAllTraces(): Promise<TraceRecord[]>: Get all recorded tracesclearTraces(): Promise<void>: Clear all tracesexportTraces(filepath: string): Promise<void>: Export traces to JSON filegetTraceStatistics(): Promise<TraceStatistics>: Get trace statistics
Execution Context
IoC owns the active execution context: which already-owned root/scope is active for the current execution flow. Ownership (instances/lifetimes) stays with the container; the context only carries which owned scope is active.
ExecutionContext.run(scope, callback): Executecallbackwithscopeactive. Preserves the callback's return shape — synchronous results stay synchronous, promises stay promises.ExecutionContext.current(): The active scope for this execution flow, orundefined.
Instances resolved through the container are context-aware on invocation:
- no active context → the call executes under the object's owning environment;
- a compatible environment (same root hierarchy, including child scopes) is active → the current context is preserved, so a root Singleton called inside a request scope keeps the request scope active;
- a conflicting environment is active →
ContextConflictError, unless an explicitExecutionContext.run(...)bridge owns the transition.
Normal application code never needs to call run() — framework entry points (request/page/operation boundaries) establish context, and directly invoking resolved objects works without wrappers. There is no process-global "current container".
Tracing Ownership
Trace state is root-owned (TraceStore), not module-global. Child containers created via extend() share the root's store; two independent roots trace concurrently without cross-talk, and clearTraces() on one root never affects another.
Decorators
@Component(options?): Mark a class as container-managed (defaults to Transient scope)@Provider(options?): Mark a class as a provider (defaults to Scoped scope). Supported for compatibility; prefer@Componentplus direct injection or class-token runtime selection in application code.@Inject(token): Specify a token for a constructor parameter
Options
interface ContainerOptions {
param?: any[]; // Dependencies or parameters
loadAs?: LoadAs; // Lifetime scope
}LoadAs Enum
enum LoadAs {
Singleton, // Single instance throughout application
Scoped, // Single instance per container scope
Transient // New instance each time
}Parameter
Parameter.create(name?): Create a new parameter. Pass a name for clearer errors and debugging output.parameter.value(value): Create a parameter value
Injectable Tokens
SCOPED_CONTAINER: A special injection token that resolves to the current container instance. Use this in parameter arrays or with@Injectonly for dynamic dependency resolution that cannot be expressed as direct constructor injection.
Component Options
interface ComponentOptions {
scope?: LoadAs; // Lifetime scope
}Real-World Use Cases
Express Application
Create one root container for shared services, then extend it per request so scoped controllers and request state do not leak across requests:
import express from 'express';
import createContainer, { Component, Inject, LoadAs, Parameter } from '@noego/ioc';
const REQUEST_ID = Parameter.create("requestId");
@Component({ scope: LoadAs.Scoped })
class RequestContext {
constructor(@Inject(REQUEST_ID) readonly requestId: string) {}
}
@Component({ scope: LoadAs.Scoped })
class UserController {
constructor(@Inject(RequestContext) private context: RequestContext) {}
getUsers(req, res) {
res.json({ requestId: this.context.requestId, users: [] });
}
}
const container = createContainer();
const app = express();
app.get('/users', async (req, res) => {
const requestContainer = container.extend();
const controller = await requestContainer.instance(UserController, [
REQUEST_ID.value(req.id),
]);
controller.getUsers(req, res);
});Running Tests
The project uses Jest for testing. To run tests:
npm testLicense
ISC
Contributing
Contributions are welcome! Here's how you can contribute to this project:
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Install dependencies (
npm install) - Make your changes
- Run tests to ensure everything works (
npm test) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Development Setup
Clone the repository:
git clone <repository-url> cd iocInstall dependencies:
npm installRun tests:
npm test
Please make sure to update tests as appropriate and follow the existing code style.
