@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/iocIf you want to use decorators, also install reflect-metadata:
npm install reflect-metadata
# or
yarn add reflect-metadataAnd configure TypeScript for decorator support in tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "ESNext",
"moduleResolution": "node",
"target": "ESNext"
}
}Problems This Solves
@noego/ioc is useful when construction rules become part of the application design instead of a few new calls.
Resolve a Whole Graph From One Entry Point
Ask the container for the controller, job, or service you want to run. Its dependencies are resolved from constructor annotations.
flowchart LR
App["App code"] --> Controller["UserController"]
Controller --> Service["UserService"]
Service --> Repo["UserRepository"]
Repo --> Db["DatabaseService"]@Component({ scope: LoadAs.Singleton })
class DatabaseService {}
@Component({ scope: LoadAs.Singleton })
class UserRepository {
constructor(@Inject(DatabaseService) private database: DatabaseService) {}
}
@Component({ scope: LoadAs.Singleton })
class UserService {
constructor(@Inject(UserRepository) private users: UserRepository) {}
}
const container = createContainer();
const service = await container.instance(UserService);Keep Request State Scoped
Use container.extend() when a request, job, page, or test needs isolated state while still sharing root singletons.
flowchart TD
Root["Root container<br/>singletons"] --> A["Request scope A"]
Root --> B["Request scope B"]
A --> StateA["RequestContext<br/>user-1"]
B --> StateB["RequestContext<br/>user-2"]const USER_ID = Parameter.create("userId");
@Component({ scope: LoadAs.Scoped })
class RequestContext {
constructor(@Inject(USER_ID) readonly userId: string) {}
}
const root = createContainer();
const requestA = root.extend();
const contextA = await requestA.instance(RequestContext, [
USER_ID.value("user-1"),
]);
const requestB = root.extend();
const contextB = await requestB.instance(RequestContext, [
USER_ID.value("user-2"),
]);Swap a Boundary in Tests
Use an abstract class as the injectable token. Production registers the real implementation; tests register a fake before resolving the class under test.
flowchart LR
Service["CheckoutService"] --> Gateway["PaymentGateway<br/>abstract token"]
Gateway --> Stripe["StripePaymentGateway<br/>production"]
Gateway -. "test registration" .-> Fake["FakePaymentGateway"]abstract class PaymentGateway {
abstract charge(amount: number): Promise<string>;
}
@Component({ scope: LoadAs.Singleton })
class CheckoutService {
constructor(@Inject(PaymentGateway) private payments: PaymentGateway) {}
}
class FakePaymentGateway extends PaymentGateway {
async charge(amount: number): Promise<string> {
return "fake-payment-id";
}
}
const testContainer = createContainer();
testContainer.registerFunction(
PaymentGateway,
() => new FakePaymentGateway(),
{ loadAs: LoadAs.Singleton },
);
const checkout = await testContainer.instance(CheckoutService);Change Behavior Per Scope
When runtime data decides which class should run, inject SCOPED_CONTAINER. The method can inspect the current environment, choose an implementation class, resolve it from the active scope, and call it.
flowchart TD
Scope["Scoped container"] --> FileOpener["FileOpener"]
FileOpener --> Os["OsService"]
FileOpener --> Container["SCOPED_CONTAINER"]
Os --> Decision{"platform?"}
Decision -->|darwin| Mac["MacOpenFileRuntime"]
Decision -->|win32| Windows["WindowsOpenFileRuntime"]
Container --> Mac
Container --> Windowsimport createContainer, {
Component,
Inject,
type IContainer,
LoadAs,
SCOPED_CONTAINER,
} from "@noego/ioc";
abstract class OpenFileRuntime {
abstract open(path: string): Promise<void>;
}
@Component({ scope: LoadAs.Singleton })
class OsService {
platform(): NodeJS.Platform {
return process.platform;
}
}
@Component({ scope: LoadAs.Singleton })
class MacOpenFileRuntime extends OpenFileRuntime {
async open(path: string): Promise<void> {
await run("open", [path]);
}
}
@Component({ scope: LoadAs.Singleton })
class WindowsOpenFileRuntime extends OpenFileRuntime {
async open(path: string): Promise<void> {
await run("cmd", ["/c", "start", path]);
}
}
@Component({ scope: LoadAs.Scoped })
class FileOpener {
constructor(
@Inject(SCOPED_CONTAINER) private container: IContainer,
@Inject(OsService) private os: OsService,
) {}
async open(path: string): Promise<void> {
const Runtime =
this.os.platform() === "win32"
? WindowsOpenFileRuntime
: MacOpenFileRuntime;
const runtime = await this.container.instance(Runtime);
await runtime.open(path);
}
}
const root = createContainer();
const scope = root.extend();
const opener = await scope.instance(FileOpener);
await opener.open("report.pdf");Quick Start
After installing and enabling decorators, create an entry file such as index.ts:
import 'reflect-metadata';
import createContainer, { Component, Inject, LoadAs } from '@noego/ioc';
@Component({ scope: LoadAs.Singleton })
class DatabaseService {}
@Component({ scope: LoadAs.Singleton })
class ExampleService {
constructor(@Inject(DatabaseService) private db: DatabaseService) {}
}
async function bootstrap() {
const container = createContainer();
const service = await container.instance(ExampleService);
console.log('Service instance:', service);
}
bootstrap();Run it with your TypeScript runner:
npx ts-node index.tsFeatures
- Dual Module Support: Compatible with CommonJS and ES Modules
- TypeScript & Typings: Built in TypeScript with bundled declaration files
- Multiple Lifetime Scopes: Support for Singleton, Transient, and Scoped dependencies
- Class & Function Registration: Register both classes and functions as dependencies
- Parameter Injection: Inject parameter values at resolution time
- Container Extension: Create child containers that inherit parent registrations
- Container Self-Injection: Use
SCOPED_CONTAINERfor rare runtime implementation selection - Decorator Support: Use
@Componentand@Injectdecorators for clean, declarative DI - Sync-First Resolution: Synchronous resolution when all dependencies are sync, with async fallback when needed
- Type-Safe Sync Mode: Generic
Syncparameter ongetandinstancefor compile-time type narrowing - Method Call Tracing: Automatic tracing of method calls with performance metrics and dependency hierarchies
- Trace Analytics: Export and analyze traces, track statistics, and monitor dependency interactions
- Lightweight: Small footprint with minimal external dependencies
Project-Learned Usage Patterns
The container supports a broad API surface, including manual param arrays, reflected constructor types, and @Provider. In larger projects that use this package heavily, the most important lesson is that IoC exists to make behavior testable through explicit seams. If a feature works manually but cannot be tested by resolving a class from a fresh container with controlled collaborators, the design is incomplete.
The safer production convention is narrower. These are conventions, not hard library limits. They come from using the container in the Demo Assistant desktop app and are the recommended reading for production code.
Stateless Singleton Operations
A stateless singleton is a class with no mutable runtime fields. It receives all changing data as method arguments and returns a result. This is where helper functions, calculations, projectors, resolvers, readers, and formatters should go once they matter enough to inject and test.
Use LoadAs.Singleton by default for these classes. They are cheap to share, easy to swap in tests, and keep repeated helper logic out of stateful services.
@Component({ scope: LoadAs.Singleton })
class PriceCalculator {
total(items: Array<{ quantity: number; unitPrice: number }>): number {
return items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0,
);
}
}
@Component({ scope: LoadAs.Singleton })
class CheckoutService {
constructor(@Inject(PriceCalculator) private prices: PriceCalculator) {}
quote(items: Array<{ quantity: number; unitPrice: number }>): number {
return this.prices.total(items);
}
}Because CheckoutService receives PriceCalculator through the container, a test can swap the calculation without mocking the module:
class FixedPriceCalculator extends PriceCalculator {
total(): number {
return 100;
}
}
const container = createContainer();
container.registerFunction(
PriceCalculator,
() => new FixedPriceCalculator(),
{ loadAs: LoadAs.Singleton },
);
const checkout = await container.instance(CheckoutService);Scoped State for Request, Page, or User Data
Use LoadAs.Scoped for mutable state tied to one request, page, user, conversation, or test case. Resolve scoped work through container.extend() so every class in that scope sees the same scoped state instance.
@Component({ scope: LoadAs.Scoped })
class RequestState {
userId: string | null = null;
}
@Component({ scope: LoadAs.Scoped })
class RequestUserWriter {
constructor(@Inject(RequestState) private state: RequestState) {}
setUser(userId: string): void {
this.state.userId = userId;
}
}
@Component({ scope: LoadAs.Scoped })
class RequestUserReader {
constructor(@Inject(RequestState) private state: RequestState) {}
currentUser(): string | null {
return this.state.userId;
}
}
const requestContainer = container.extend();
const writer = await requestContainer.instance(RequestUserWriter);
const reader = await requestContainer.instance(RequestUserReader);Do not thread snapshots through constructors, mirror scoped state into multiple services, or inject a stateful facade back into extracted domain classes. Put the shared mutable fact in one scoped state class and inject that state where needed.
Transient Instances for Unique Objects
Use LoadAs.Transient rarely. It is for objects where every resolution must produce a fresh instance, such as an isolated builder, cursor, or accumulator.
@Component({ scope: LoadAs.Transient })
class ReportBuilder {
private sections: string[] = [];
addSection(text: string): void {
this.sections.push(text);
}
build(): string {
return this.sections.join("\n\n");
}
}
const first = await container.instance(ReportBuilder);
const second = await container.instance(ReportBuilder);If the class has no mutable fields, prefer LoadAs.Singleton. If the class has mutable state that should be shared within one request or page, prefer LoadAs.Scoped.
Explicit Constructor Injection
Add @Inject(...) to every constructor parameter, including concrete classes. Reflected constructor metadata is a fallback, not a project convention.
@Component({ scope: LoadAs.Singleton })
class UserService {
constructor(
@Inject(UserRepository) private users: UserRepository,
@Inject(PriceCalculator) private prices: PriceCalculator,
) {}
}Prefer direct injection when a dependency has one implementation. Do not introduce a provider or runtime selector just to wrap container.instance(SomeClass).
Abstract Contracts for Swappable Boundaries
Use abstract classes, not TypeScript interfaces, for injectable runtime contracts. Interfaces are erased at runtime, while abstract classes can be used as container tokens.
abstract class EmailSender {
abstract send(to: string, body: string): Promise<void>;
}
@Component({ scope: LoadAs.Singleton })
class SmtpEmailSender extends EmailSender {
async send(to: string, body: string): Promise<void> {
// Send through SMTP.
}
}
@Component({ scope: LoadAs.Singleton })
class InviteService {
constructor(@Inject(EmailSender) private email: EmailSender) {}
}Tests can register a fake implementation before resolving the class under test:
class FakeEmailSender extends EmailSender {
sent: Array<{ to: string; body: string }> = [];
async send(to: string, body: string): Promise<void> {
this.sent.push({ to, body });
}
}
container.registerFunction(
EmailSender,
() => new FakeEmailSender(),
{ loadAs: LoadAs.Singleton },
);Use co-located .mock.ts files for these test registrations. Do not use vi.mock() for internal IoC services; it bypasses the same seam production code uses.
Runtime Parameters for Scoped Values
Use Parameter.create(...) when a class needs runtime values such as tenant IDs, user IDs, or config strings alongside injected dependencies. The value can be passed at the top-level resolution and consumed by a dependency deeper in the graph.
const USER_ID = Parameter.create("userId");
const USER_ROLE = Parameter.create("userRole");
@Component({ scope: LoadAs.Scoped })
class CurrentUser {
constructor(
@Inject(USER_ID) readonly userId: string,
@Inject(USER_ROLE) readonly role: "admin" | "member",
) {}
}
@Component({ scope: LoadAs.Singleton })
class UserRepository {
async findUser(userId: string): Promise<User> {
// Query the database.
// ...
}
}
@Component({ scope: LoadAs.Scoped })
class PermissionReader {
constructor(
@Inject(CurrentUser) private user: CurrentUser,
@Inject(UserRepository) private users: UserRepository,
) {}
async canEditDocument(): Promise<boolean> {
const owner = await this.users.findUser(this.user.userId);
return this.user.role === "admin" || owner.id === this.user.userId;
}
}
@Component({ scope: LoadAs.Scoped })
class DocumentController {
constructor(@Inject(PermissionReader) private permissions: PermissionReader) {}
async canEdit(): Promise<boolean> {
return this.permissions.canEditDocument();
}
}
const requestContainer = container.extend();
const controller = await requestContainer.instance(DocumentController, [
USER_ID.value("user-123"),
USER_ROLE.value("admin"),
]);DocumentController does not inject USER_ID or USER_ROLE directly, but both values still reach CurrentUser through PermissionReader. If the parameter value changes per request, keep the parameter-consuming class Scoped or Transient; a Singleton would keep the first value it was constructed with.
Scoped Container for Runtime Class Selection
Use SCOPED_CONTAINER only when runtime data genuinely selects among multiple implementation classes. Select the implementation class token, then resolve that class through the active scoped container.
@Component({ scope: LoadAs.Scoped })
class RuntimeLauncher {
constructor(@Inject(SCOPED_CONTAINER) private container: IContainer) {}
async launch(runtime: "local" | "remote"): Promise<void> {
const RuntimeClass = runtime === "local" ? LocalRuntime : RemoteRuntime;
const instance = await this.container.instance(RuntimeClass);
await instance.start();
}
}Avoid @Provider in application code unless you are maintaining legacy code that already uses it. A provider that only wraps container.instance(SomeClass) is usually unnecessary factory indirection.
Resolve Through the Container
Do not manually instantiate IoC classes with new; resolve them through the container so dependencies, lifetimes, tracing, and overrides all apply.
// Good
const service = await container.instance(CheckoutService);
// Avoid
const service = new CheckoutService(new PriceCalculator());Testability Defines Done
Testability is the reason for the pattern, not a follow-up task. New code should have an obvious test seam before it is considered finished.
Good IoC tests:
- Create a fresh
new Container()orcreateContainer()per test. - Register fakes before resolving the class under test.
- Resolve the real class through the container.
- Drive behavior through public methods or controller/input methods.
- Assert at the nearest owned boundary: domain method, writer, repository, adapter, validator, or controller.
Bad IoC tests:
- Import the app's shared container.
- Use
vi.mock()for internal services. - Reach into private fields,
@internalgetters, or test-only backdoors. - Manually instantiate a container-managed class and hand-wire its dependencies.
- Use an end-to-end test to cover behavior that could be tested through a smaller owned seam.
Example:
// payment_processor.mock.ts
import type { IContainer } from "@noego/ioc";
import { LoadAs } from "@noego/ioc";
import { PaymentProcessor } from "./payment_processor";
class MockPaymentProcessor extends PaymentProcessor {
async charge(amount: number): Promise<string> {
if (amount <= 0) {
throw new Error("amount must be positive");
}
return "mock-payment-id";
}
}
export function mockPaymentProcessor(container: IContainer): void {
container.registerFunction(PaymentProcessor, () => new MockPaymentProcessor(), {
loadAs: LoadAs.Singleton,
});
}
// order_service.test.ts
it("charges through the configured processor", async () => {
const container = createContainer();
mockPaymentProcessor(container);
const service = await container.instance(OrderService);
await expect(service.placeOrder({ amount: 25 })).resolves.toEqual({
status: "paid",
paymentId: "mock-payment-id",
});
});Boundary fakes should enforce the same validation as real boundary implementations. A fake that accepts illegal payloads gives false confidence.
ESM vs CJS imports
- Modern Node (>=14.13, >=16 recommended) and bundlers that honor
package.exportscan use either:import { createContainer } from '@noego/ioc'import createContainer from '@noego/ioc'
- If you see “does not provide an export named 'createContainer'”, your toolchain likely resolved the CommonJS build. Use this interop-safe pattern:
import pkg from '@noego/ioc'; const { createContainer } = pkg;- Or upgrade Node to a version that supports conditional exports.
Usage
Manual Registration
import createContainer from "@noego/ioc";
const container = createContainer();
container.registerClass(Database);
container.registerClass(UserRepository, { param: [Database] });
container.registerClass(UserService, { param: [UserRepository] });
const userService = await container.instance(UserService);Lifetime Scopes
The container supports three different lifetime scopes:
- Transient: A new instance is created every time the dependency is resolved
- Singleton: Only one instance is created and reused throughout the application
- Scoped: A single instance is created per container scope
import { Component, Inject, LoadAs } from "@noego/ioc";
@Component({ scope: LoadAs.Singleton })
class Database {}
@Component({ scope: LoadAs.Scoped })
class RequestContext {
constructor(@Inject(Database) private db: Database) {}
}If you use manual registration, loadAs is still available and overrides the decorator scope. In application code, prefer the decorator scope so the class owns its lifetime.
Parameter Injection
You can inject parameter values at resolution time:
import { Parameter } from "@noego/ioc";
class User {
constructor(public id: number, public name: string) {}
}
// Create parameters
const USER_ID = Parameter.create("userId");
const USER_NAME = Parameter.create("userName");
// Register with parameters
container.registerClass(User, { param: [USER_ID, USER_NAME] });
// Resolve with parameter values
async function createUser() {
const user = await container.instance(User, [
USER_ID.value(1),
USER_NAME.value("John")
]);
console.log(user.id, user.name); // 1, "John"
}Function Registration
You can also register functions as dependencies:
function createLogger(prefix: string) {
return {
log: (message: string) => console.log(`${prefix}: ${message}`)
};
}
const PREFIX = Parameter.create("prefix");
// Register function
container.registerFunction("logger", createLogger, {
param: [PREFIX]
});
// Resolve function
async function useLogger() {
const logger = await container.get("logger", [PREFIX.value("APP")]);
logger.log("Application started"); // "APP: Application started"
}Using Decorators
After decorator support is configured, use @Component and @Inject to make class dependencies explicit.
Component Decorator
Use @Component to mark a class as a component with an optional scope:
import { Component, Inject, LoadAs } from '@noego/ioc';
@Component({ scope: LoadAs.Singleton })
class UserService {
// ...
}
@Component({ scope: LoadAs.Singleton })
class DatabaseService {
// ...
}
@Component({ scope: LoadAs.Scoped })
class RequestContext {
// ...
}Inject Decorator
Use @Inject to specify the dependency token for each constructor parameter. This is required by project convention even when the parameter type is concrete:
import { Component, Inject, LoadAs } from '@noego/ioc';
// Use an abstract class for injectable contracts. Interfaces are erased at runtime.
abstract class Logger {
abstract log(message: string): void;
}
@Component({ scope: LoadAs.Singleton })
class ConsoleLogger extends Logger {
log(message: string) {
console.log(message);
}
}
@Component({ scope: LoadAs.Singleton })
class DatabaseService {}
@Component({ scope: LoadAs.Singleton })
class UserService {
constructor(
@Inject(Logger) private logger: Logger,
@Inject(DatabaseService) private database: DatabaseService,
) {}
createUser() {
this.logger.log('Creating user...');
// ...
}
}
// Register
const container = createContainer();
container.registerFunction(Logger, () => container.instance(ConsoleLogger), {
loadAs: LoadAs.Singleton,
});
// Resolve
const service = await container.instance(UserService);Override Priority
Manual registration options take precedence over decorators:
- Manually defined parameters in
registerClass({ param: [...] })override constructor parameter types and@Injectannotations. - Manually defined scope in
registerClass({ loadAs: ... })overrides@Component({ scope: ... }).
This allows you to change behavior at registration time without modifying the decorated class.
Sync Resolution
By default, get and instance return Promise<T> | T. When your entire dependency graph is synchronous (no async factory functions), the container resolves synchronously. If you know at the call site that resolution will be sync, pass true as the second generic parameter to get a narrowed T return type:
// Default — returns Promise<T> | T
const service = container.instance(UserService);
// When you know the dependency graph is sync — returns T
const service = container.instance<UserService, true>(UserService);
const logger = container.get<Logger, true>(Logger);This is purely a compile-time hint — no runtime behavior changes. If a dependency turns out to be async at runtime, you'll get a Promise back regardless of the type annotation.
Extending Containers
You can create a child container that inherits all the registrations from the parent but allows overriding:
// Create parent container
const parentContainer = createContainer();
parentContainer.registerClass(Database);
// Create child container
const childContainer = parentContainer.extend();
// Override in child container
childContainer.registerClass(Database, { /* different configuration */ });
// Parent container still uses the original registration
// Child container uses the new registrationRuntime Selection with SCOPED_CONTAINER
Prefer direct constructor injection for normal dependencies. Use SCOPED_CONTAINER only when runtime data genuinely selects among multiple implementation classes. The selected value should be the implementation class token itself, not a string mode that later gets switched back into a class.
Use this for plugin systems, multi-tenancy, user-selected implementations, and request-specific routing. The earlier FileOpener example shows the pattern: inject the scoped container, choose an implementation class from runtime data, resolve that class, then call it. Do not introduce a factory/provider class when direct injection or a thin class-token selection is enough.
The @Provider decorator still exists for compatibility. In project code, treat it as legacy or exceptional. A provider whose only job is to call container.instance(SomeClass) adds indirection without adding a boundary.
Method Call Tracing and Monitoring
The container supports automatic tracing of method calls on resolved instances. This is useful for debugging, monitoring, and understanding dependency interactions in your application.
Enabling Tracing
const container = createContainer();
// Enable tracing
container.setTracingEnabled(true);
// Optional: Set trace retention (default is 5 minutes)
container.setTraceRetentionMinutes(10);
// Register your classes
container.registerClass(DatabaseService);
container.registerClass(UserService);
// When instances are resolved, method calls are automatically traced
const service = await container.get(UserService);
service.getUsers(); // This call will be tracedRetrieving Traces
// Get recent traces within retention window
const traces = await container.getTraces();
console.log(traces);
// Get all traces ever recorded
const allTraces = await container.getAllTraces();
// Get trace statistics
const stats = await container.getTraceStatistics();
console.log(`Total method calls traced: ${stats.totalTraces}`);
console.log(`Total proxies created: ${stats.totalProxies}`);How Tracing Works
When tracing is enabled:
- Automatic Wrapping: Each resolved instance is wrapped in a JavaScript Proxy that intercepts method calls
- Call Recording: Every method call is recorded with:
- Method name and parameters
- Return value or error (if thrown)
- Execution duration in milliseconds
- Parent-child dependency relationships
- Zero Overhead When Disabled: When tracing is disabled, instances are not wrapped and there's no performance impact
- Database Storage: Traces are stored in-memory using sql.js (pure JavaScript SQLite)
- Automatic Cleanup: Old traces are automatically cleaned up based on retention settings
Trace Statistics
The trace statistics provide insights into your application's dependency interactions:
const stats = await container.getTraceStatistics();
// Example output:
// {
// totalTraces: 42, // Total method calls recorded
// totalProxies: 5, // Total unique instances traced
// proxiesByClass: {
// UserService: 1,
// DatabaseService: 1,
// UserRepository: 1
// },
// methodCallsByProxy: {
// 1: 12, // Proxy 1 had 12 method calls
// 2: 8, // Proxy 2 had 8 method calls
// // ...
// }
// }Exporting and Analyzing Traces
// Export traces to JSON for analysis
const exported = await TraceLoggerModule.exportTracesToJson();
// or use container method
await container.exportTraces('./traces.json');
// Clear traces
await container.clearTraces();Tracing with Dependency Hierarchies
When an instance depends on other instances, the tracing system records the parent-child relationships:
@Component({ scope: LoadAs.Singleton })
class Database {
query() { return 'data'; }
}
@Component({ scope: LoadAs.Singleton })
class UserService {
constructor(@Inject(Database) private db: Database) {}
getUsers() { return this.db.query(); }
}
const container = createContainer();
container.setTracingEnabled(true);
const service = await container.get(UserService);
await service.getUsers();
// Traces will show the call hierarchy:
// UserService.getUsers() -> Database.query()API Reference
Container
createContainer(): Creates a new IoC containerregisterClass<T>(classDefinition, options?): Register a classregisterFunction(label, function, options?): Register a functioninstance<T, Sync>(classDefinition, params?): Resolve a class instance. PassSync = truefor sync type narrowingget<T, Sync>(label, params?): Resolve a dependency by key. PassSync = truefor sync type narrowingextend(): Create a child containersetTracingEnabled(enabled: boolean): Enable/disable method call tracingisTracingEnabled(): boolean: Check if tracing is enabledsetTraceRetentionMinutes(minutes: number): Set trace retention windowgetTraces(retentionMinutes?: number): Promise<TraceRecord[]>: Get recent tracesgetAllTraces(): Promise<TraceRecord[]>: Get all recorded tracesclearTraces(): Promise<void>: Clear all tracesexportTraces(filepath: string): Promise<void>: Export traces to JSON filegetTraceStatistics(): Promise<TraceStatistics>: Get trace statistics
Decorators
@Component(options?): Mark a class as container-managed (defaults to Transient scope)@Provider(options?): Mark a class as a provider (defaults to Scoped scope). Supported for compatibility; prefer@Componentplus direct injection or class-token runtime selection in application code.@Inject(token): Specify a token for a constructor parameter
Options
interface ContainerOptions {
param?: any[]; // Dependencies or parameters
loadAs?: LoadAs; // Lifetime scope
}LoadAs Enum
enum LoadAs {
Singleton, // Single instance throughout application
Scoped, // Single instance per container scope
Transient // New instance each time
}Parameter
Parameter.create(name?): Create a new parameter. Pass a name for clearer errors and debugging output.parameter.value(value): Create a parameter value
Injectable Tokens
SCOPED_CONTAINER: A special injection token that resolves to the current container instance. Use this in parameter arrays or with@Injectonly for dynamic dependency resolution that cannot be expressed as direct constructor injection.
Component Options
interface ComponentOptions {
scope?: LoadAs; // Lifetime scope
}Real-World Use Cases
Express Application
Create one root container for shared services, then extend it per request so scoped controllers and request state do not leak across requests:
import express from 'express';
import createContainer, { Component, Inject, LoadAs, Parameter } from '@noego/ioc';
const REQUEST_ID = Parameter.create("requestId");
@Component({ scope: LoadAs.Scoped })
class RequestContext {
constructor(@Inject(REQUEST_ID) readonly requestId: string) {}
}
@Component({ scope: LoadAs.Scoped })
class UserController {
constructor(@Inject(RequestContext) private context: RequestContext) {}
getUsers(req, res) {
res.json({ requestId: this.context.requestId, users: [] });
}
}
const container = createContainer();
const app = express();
app.get('/users', async (req, res) => {
const requestContainer = container.extend();
const controller = await requestContainer.instance(UserController, [
REQUEST_ID.value(req.id),
]);
controller.getUsers(req, res);
});Running Tests
The project uses Jest for testing. To run tests:
npm testLicense
ISC
Contributing
Contributions are welcome! Here's how you can contribute to this project:
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Install dependencies (
npm install) - Make your changes
- Run tests to ensure everything works (
npm test) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Development Setup
Clone the repository:
git clone <repository-url> cd iocInstall dependencies:
npm installRun tests:
npm test
Please make sure to update tests as appropriate and follow the existing code style.
