npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 throws LateInstanceDecoratorError. 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/ioc

If you want to use decorators, also install reflect-metadata:

npm install reflect-metadata
# or
yarn add reflect-metadata

And 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 --> Windows
import 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.ts

Features

  • 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_CONTAINER for rare runtime implementation selection
  • Decorator Support: Use @Component and @Inject decorators for clean, declarative DI
  • Sync-First Resolution: Synchronous resolution when all dependencies are sync, with async fallback when needed
  • Type-Safe Sync Mode: Generic Sync parameter on get and instance for 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() or createContainer() 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, @internal getters, 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.exports can 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:

  1. Transient: A new instance is created every time the dependency is resolved
  2. Singleton: Only one instance is created and reused throughout the application
  3. 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:

  1. Manually defined parameters in registerClass({ param: [...] }) override constructor parameter types and @Inject annotations.
  2. 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 registration

Runtime 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 traced

Retrieving 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:

  1. Automatic Wrapping: Each resolved instance is wrapped in a JavaScript Proxy that intercepts method calls
  2. 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
  3. Zero Overhead When Disabled: When tracing is disabled, instances are not wrapped and there's no performance impact
  4. Database Storage: Traces are stored in-memory using sql.js (pure JavaScript SQLite)
  5. 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 container
  • registerClass<T>(classDefinition, options?): Register a class
  • registerFunction(label, function, options?): Register a function
  • instance<T, Sync>(classDefinition, params?): Resolve a class instance. Pass Sync = true for sync type narrowing
  • get<T, Sync>(label, params?): Resolve a dependency by key. Pass Sync = true for sync type narrowing
  • extend(): Create a child container
  • setTracingEnabled(enabled: boolean): Enable/disable method call tracing
  • isTracingEnabled(): boolean: Check if tracing is enabled
  • setTraceRetentionMinutes(minutes: number): Set trace retention window
  • getTraces(retentionMinutes?: number): Promise<TraceRecord[]>: Get recent traces
  • getAllTraces(): Promise<TraceRecord[]>: Get all recorded traces
  • clearTraces(): Promise<void>: Clear all traces
  • exportTraces(filepath: string): Promise<void>: Export traces to JSON file
  • getTraceStatistics(): 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): Execute callback with scope active. Preserves the callback's return shape — synchronous results stay synchronous, promises stay promises.
  • ExecutionContext.current(): The active scope for this execution flow, or undefined.

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 explicit ExecutionContext.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 @Component plus 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 @Inject only 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 test

License

ISC

Contributing

Contributions are welcome! Here's how you can contribute to this project:

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Install dependencies (npm install)
  4. Make your changes
  5. Run tests to ensure everything works (npm test)
  6. Commit your changes (git commit -m 'Add some amazing feature')
  7. Push to the branch (git push origin feature/amazing-feature)
  8. Open a Pull Request

Development Setup

  1. Clone the repository:

    git clone <repository-url>
    cd ioc
  2. Install dependencies:

    npm install
  3. Run tests:

    npm test

Please make sure to update tests as appropriate and follow the existing code style.