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

Published

A self contained IoC container for Node.js

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.

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.

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

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.