@noego/ioc
v0.2.3
Published
A self contained IoC container for Node.js
Downloads
512
Readme
@noego/ioc
A lightweight, flexible Inversion of Control (IoC) container for Node.js and TypeScript applications, providing support for multiple dependency lifetime scopes, parameter injection, and both class and function registration with full type safety.
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 the provider pattern with
SCOPED_CONTAINERfor dynamic service creation and factories - Decorator Support: Use @Component, @Provider, and @Inject decorators for clean, declarative DI
- TypeScript Support: Built with full TypeScript support for type safety
- 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
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"
}
}Quick Start
Follow these minimal steps to get @noego/ioc working in a TypeScript project:
Install packages:
npm install @noego/ioc reflect-metadataConfigure
tsconfig.json:{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true, "module": "ESNext", "moduleResolution": "node", "target": "ESNext" }, "include": ["src"] }Create an entry file (
index.ts):import 'reflect-metadata'; import createContainer, { Component } from '@noego/ioc'; @Component() class ExampleService {} async function bootstrap() { const container = createContainer(); container.registerClass(ExampleService); const svc = await container.instance(ExampleService); console.log('Service instance:', svc); } bootstrap();
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.
- Run the entry file:
npx ts-node index.ts
Getting Started
Using manual registration:
import createContainer from "@noego/ioc";
// Create a container
const container = createContainer();
// Register your dependencies
container.registerClass(Database);
container.registerClass(UserRepository, { param: [Database] });
// Resolve and use
async function main() {
const repo = await container.instance(UserRepository);
const users = repo.getUsers();
}
main();Using decorators:
import createContainer, { Component, Inject } from "@noego/ioc";
import 'reflect-metadata'; // Required when using decorators
@Component({ scope: LoadAs.Singleton })
class Database {
connect() {
return "Connected to DB";
}
}
@Component()
class UserRepository {
constructor(private db: Database) {}
getUsers() {
this.db.connect();
return ["User1", "User2"];
}
}
// Create container and register classes
const container = createContainer();
container.registerClass(Database);
container.registerClass(UserRepository);
// Resolve and use
async function main() {
const repo = await container.instance(UserRepository);
const users = repo.getUsers();
}
main();Usage
Basic Usage
import createContainer from "@noego/ioc";
// Create a container
const container = createContainer();
// Define classes
class Database {
connect() {
return "Connected to DB";
}
}
class UserRepository {
constructor(private db: Database) {}
getUsers() {
this.db.connect();
return ["User1", "User2"];
}
}
class UserService {
constructor(private repo: UserRepository) {}
getAllUsers() {
return this.repo.getUsers();
}
}
// Register dependencies
container.registerClass(Database);
container.registerClass(UserRepository, { param: [Database] });
container.registerClass(UserService, { param: [UserRepository] });
// Resolve dependencies
async function run() {
const userService = await container.instance(UserService);
const users = userService.getAllUsers();
console.log(users); // ["User1", "User2"]
}
run();Lifetime Scopes
The container supports three different lifetime scopes:
- Transient (default): 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 { LoadAs } from "@noego/ioc";
// Register a singleton
container.registerClass(Database, { loadAs: LoadAs.Singleton });
// Register a scoped dependency
container.registerClass(UserRepository, {
param: [Database],
loadAs: LoadAs.Scoped
});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();
const USER_NAME = Parameter.create();
// 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();
// 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
The container supports decorator-based dependency injection using @Component and @Inject.
Setup
First, ensure TypeScript is configured to support decorators:
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
// other options...
}
}Also, import reflect-metadata once at your application's entry point:
// index.ts or main.ts
import 'reflect-metadata';
// ... rest of your codeComponent Decorator
Use @Component to mark a class as a component with an optional scope:
import { Component, LoadAs } from '@noego/ioc';
@Component() // Default is Transient
class UserService {
// ...
}
@Component({ scope: LoadAs.Singleton })
class DatabaseService {
// ...
}
@Component({ scope: LoadAs.Scoped })
class RequestContext {
// ...
}Inject Decorator
Use @Inject to specify tokens for interface dependencies or to override constructor parameter types:
import { Component, Inject } from '@noego/ioc';
// Define an interface
interface ILogger {
log(message: string): void;
}
// Create a token for the interface
const LoggerToken = Symbol('ILogger');
// Implement the interface
@Component({ scope: LoadAs.Singleton })
class ConsoleLogger implements ILogger {
log(message: string) {
console.log(message);
}
}
// Use @Inject to specify which implementation to use
@Component()
class UserService {
constructor(
// Use @Inject with a token
@Inject(LoggerToken) private logger: ILogger,
// Regular parameter - resolved by type
private database: DatabaseService
) {}
createUser() {
this.logger.log('Creating user...');
// ...
}
}
// Register
const container = createContainer();
container.registerClass(DatabaseService);
container.registerClass(ConsoleLogger);
container.registerFunction(LoggerToken, () => container.instance(ConsoleLogger));
container.registerClass(UserService);
// 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 registrationProvider Pattern with SCOPED_CONTAINER
The @Provider decorator creates factory classes that can dynamically resolve dependencies at runtime. Use @Inject(SCOPED_CONTAINER) to inject the container itself, enabling polymorphic service creation based on runtime conditions.
import createContainer, { Provider, Inject, SCOPED_CONTAINER, IContainer, Component } from "@noego/ioc";
// Define payment processor implementations
interface PaymentProcessor {
process(amount: number): Promise<string>;
}
@Component()
class StripeProcessor implements PaymentProcessor {
async process(amount: number) {
return `Processed $${amount} via Stripe`;
}
}
@Component()
class PayPalProcessor implements PaymentProcessor {
async process(amount: number) {
return `Processed $${amount} via PayPal`;
}
}
// Provider that creates the right processor at runtime
@Provider()
class PaymentProcessorFactory {
constructor(
@Inject(SCOPED_CONTAINER) private container: IContainer
) {}
async getProcessor(type: 'stripe' | 'paypal'): Promise<PaymentProcessor> {
switch (type) {
case 'stripe':
return this.container.instance(StripeProcessor);
case 'paypal':
return this.container.instance(PayPalProcessor);
default:
throw new Error(`Unknown payment processor: ${type}`);
}
}
}
// Service that injects the factory and changes behavior at runtime
@Component()
class OrderService {
constructor(private paymentFactory: PaymentProcessorFactory) {}
async processOrder(amount: number, paymentMethod: 'stripe' | 'paypal') {
// Get the right processor based on runtime parameter
const processor = await this.paymentFactory.getProcessor(paymentMethod);
// Behavior changes based on what the user selected
const result = await processor.process(amount);
return { success: true, message: result };
}
}
// Usage - behavior determined at runtime
const container = createContainer();
const orderService = await container.instance(OrderService);
await orderService.processOrder(100, 'stripe'); // Uses Stripe
await orderService.processOrder(200, 'paypal'); // Uses PayPalThis pattern is ideal for plugin systems, multi-tenancy, polymorphic services, and any scenario where you need to select implementations based on runtime data. The @Provider decorator defaults to Scoped lifetime, making instances scoped per container context.
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()
class Database {
query() { return 'data'; }
}
@Component()
class UserService {
constructor(private db: Database) {}
getUsers() { return this.db.query(); }
}
const container = createContainer();
container.setTracingEnabled(true);
container.registerClass(Database);
container.registerClass(UserService);
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
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, ideal for factories)@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(): Create a new parameterparameter.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@Injectto enable the provider pattern and dynamic dependency resolution.
Component Options
interface ComponentOptions {
scope?: LoadAs; // Lifetime scope
}Real-World Use Cases
Express Application
import express from 'express';
import createContainer, { LoadAs } from '@noego/ioc';
// Create services
class ConfigService {
getConfig() {
return { port: 3000 };
}
}
class DatabaseService {
constructor(private config: ConfigService) {}
connect() {
console.log('Connected to database');
return {};
}
}
class UserRepository {
constructor(private db: DatabaseService) {}
findAll() {
return [{ id: 1, name: 'User 1' }];
}
}
class UserController {
constructor(private repo: UserRepository) {}
getUsers(req, res) {
const users = this.repo.findAll();
res.json(users);
}
}
// Setup container
const container = createContainer();
container.registerClass(ConfigService, { loadAs: LoadAs.Singleton });
container.registerClass(DatabaseService, { param: [ConfigService], loadAs: LoadAs.Singleton });
container.registerClass(UserRepository, { param: [DatabaseService] });
container.registerClass(UserController, { param: [UserRepository] });
// Create express app
const app = express();
// Setup routes using the container
app.get('/users', async (req, res) => {
const controller = await container.instance(UserController);
controller.getUsers(req, res);
});
// Start server
async function bootstrap() {
const config = await container.instance(ConfigService);
app.listen(config.getConfig().port, () => {
console.log(`Server running on port ${config.getConfig().port}`);
});
}
bootstrap();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.
