A lightweight, type-safe TypeScript IoC (Inversion of Control) container and dependency injection library with decorator support.
- Type-safe dependency injection using TypeScript decorators
- Multiple provider types: Class, Value, and Factory providers
- Provider grouping with
@Group()decorator for organizing related providers - NestJS-style bootstrapping for easy application initialization
- Injectable metadata for storing custom service information
- Lazy injection support for circular dependencies
- Automatic dependency resolution with circular dependency detection
- Lifecycle hooks with
OnInitandOnDestroyinterfaces (NestJS-style) - Container cleanup with
destroy()method for proper resource management - Configurable logging with OFF, MINIMAL, and VERBOSE levels
- Dependency graph visualization for debugging
- Smart resolution ordering based on dependency weights
- Singleton pattern - all resolved instances are cached
npm install @cryxto/ioc-n-di reflect-metadataor with bun:
bun add @cryxto/ioc-n-di reflect-metadataImportant: This library requires reflect-metadata as a peer dependency.
Add these settings to your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}import 'reflect-metadata';
import { Container, Injectable } from '@cryxto/ioc-n-di';
@Injectable()
class Logger {
log(message: string) {
console.log(`[LOG]: ${message}`);
}
}
@Injectable()
class UserService {
constructor(private logger: Logger) {}
getUser(id: number) {
this.logger.log(`Fetching user ${id}`);
return { id, name: 'John Doe' };
}
}
// Bootstrap
const container = Container.createOrGet();
container.register(Logger);
container.register(UserService);
const userService = await container.resolve(UserService);
const user = userService.getUser(1);
// Output: [LOG]: Fetching user 1import 'reflect-metadata';
import { Container, Injectable } from '@cryxto/ioc-n-di';
@Injectable()
class Database {
connect() {
console.log('Connected to database');
}
}
@Injectable()
class UserRepository {
constructor(private db: Database) {}
findAll() {
this.db.connect();
return ['user1', 'user2'];
}
}
const container = Container.createOrGet();
container.register(Database);
container.register(UserRepository);
const repo = await container.resolve(UserRepository);
repo.findAll();Use tokens when you need to inject interfaces or specific implementations:
import { Container, Injectable, Inject } from '@cryxto/ioc-n-di';
// Define tokens
const DATABASE_URL = Symbol('DATABASE_URL');
const API_KEY = Symbol('API_KEY');
@Injectable()
class ApiService {
constructor(
@Inject(DATABASE_URL) private dbUrl: string,
@Inject(API_KEY) private apiKey: string,
) {}
connect() {
console.log(`Connecting to ${this.dbUrl} with key ${this.apiKey}`);
}
}
const container = Container.createOrGet();
// Register value providers
container.register({
provide: DATABASE_URL,
useValue: 'postgresql://localhost:5432/mydb',
});
container.register({
provide: API_KEY,
useValue: 'secret-key-123',
});
container.register(ApiService);
const service = await container.resolve(ApiService);
service.connect();
// Output: Connecting to postgresql://localhost:5432/mydb with key secret-key-123Create instances using factory functions:
import { Container } from '@cryxto/ioc-n-di';
const CONFIG_TOKEN = Symbol('CONFIG');
const HTTP_CLIENT = Symbol('HTTP_CLIENT');
container.register({
provide: CONFIG_TOKEN,
useValue: { baseUrl: 'https://api.example.com', timeout: 5000 },
});
container.register({
provide: HTTP_CLIENT,
useFactory: (config) => {
return {
get: (url: string) => fetch(`${config.baseUrl}${url}`),
timeout: config.timeout,
};
},
deps: [CONFIG_TOKEN],
});
const httpClient = await container.resolve(HTTP_CLIENT);Handle circular dependencies using lazy references:
import { Container, Injectable, Lazy, LazyRef } from '@cryxto/ioc-n-di';
@Injectable()
class ServiceA {
constructor(@Lazy(ServiceB) private serviceB: LazyRef<ServiceB>) {}
doSomething() {
console.log('ServiceA doing something');
// Access ServiceB lazily when needed
this.serviceB.value.doSomethingElse();
}
}
@Injectable()
class ServiceB {
constructor(@Lazy(ServiceA) private serviceA: LazyRef<ServiceA>) {}
doSomethingElse() {
console.log('ServiceB doing something else');
}
}
const container = Container.createOrGet();
container.register(ServiceA);
container.register(ServiceB);
const serviceA = await container.resolve(ServiceA);
serviceA.doSomething();
// Output:
// ServiceA doing something
// ServiceB doing something elseExecute initialization and cleanup logic using NestJS-style lifecycle interfaces:
import { Container, Injectable, OnInit, OnDestroy } from '@cryxto/ioc-n-di';
@Injectable()
class DatabaseService implements OnInit, OnDestroy {
private connection: any;
// Called after instance is created and dependencies are injected
async onInit() {
console.log('Connecting to database...');
this.connection = await createConnection();
}
// Called when container.destroy() is invoked
async onDestroy() {
console.log('Closing database connection...');
await this.connection.close();
}
query(sql: string) {
return this.connection.query(sql);
}
}
const container = Container.createOrGet();
container.register(DatabaseService);
const db = await container.resolve(DatabaseService);
// onInit was called automatically
// During application shutdown
await container.destroy();
// onDestroy was called automaticallyYou can also use lifecycle hooks directly in provider configuration:
container.register({
provide: 'DATABASE',
useFactory: () => createConnection(),
onInit: async (conn) => {
console.log('Database connection initialized');
await conn.authenticate();
},
onDestroy: async (conn) => {
console.log('Closing database connection');
await conn.close();
},
});
const db = await container.resolve('DATABASE');
// onInit was called
await container.destroy();
// onDestroy was calledProvider hooks run before instance hooks:
@Injectable()
class Service implements OnInit {
async onInit() {
console.log('2. Instance onInit');
}
}
container.register({
provide: Service,
useClass: Service,
onInit: async (instance) => {
console.log('1. Provider onInit');
},
});
await container.resolve(Service);
// Output:
// 1. Provider onInit
// 2. Instance onInitControl the verbosity of container logging output:
import { Container, LogLevel } from '@cryxto/ioc-n-di';
const container = Container.createOrGet();
// Disable all logging (recommended for production)
container.setLogLevel(LogLevel.OFF);
// Minimal logging - only important events (bootstrap, destroy)
container.setLogLevel(LogLevel.MINIMAL);
// Verbose logging - all registration and resolution details (default)
container.setLogLevel(LogLevel.VERBOSE);
// Check current log level
const currentLevel = container.getLogLevel();
console.log(currentLevel); // 'VERBOSE', 'MINIMAL', or 'OFF'LogLevel.OFF- No logging output at all (recommended for production)LogLevel.MINIMAL- Only logs important events:- Bootstrap start and completion
- Container destruction
- Errors during cleanup
LogLevel.VERBOSE- Logs everything (default, useful for debugging):- All provider registrations
- All dependency resolutions
- Lifecycle hook invocations
- Lazy reference creation
- All minimal events
import { Container, LogLevel } from '@cryxto/ioc-n-di';
const container = Container.createOrGet();
// Disable logs in production, enable in development
if (process.env.NODE_ENV === 'production') {
container.setLogLevel(LogLevel.OFF);
} else {
container.setLogLevel(LogLevel.VERBOSE);
}
await container.bootstrap([
ConfigService,
DatabaseService,
AppService
]);The easiest way to initialize your application - register and resolve all providers at once:
import { Container, Injectable } from '@cryxto/ioc-n-di';
@Injectable()
class ConfigService {
getPort() { return 3000; }
}
@Injectable()
class DatabaseService {
constructor(private config: ConfigService) {}
async connect() {
console.log('Database connected');
}
}
@Injectable()
class AppService {
constructor(
private config: ConfigService,
private db: DatabaseService,
) {}
}
// Bootstrap everything at once
const container = await Container.createOrGet().bootstrap([
ConfigService,
DatabaseService,
AppService,
// You can also mix in value and factory providers
{ provide: 'API_KEY', useValue: 'secret-key' }
]);
// All services are now initialized and ready to use
const app = container.getInstanceOrThrow(AppService);Alternative syntax with configuration object:
await container.bootstrap({
providers: [ConfigService, DatabaseService, AppService]
});Store custom metadata with your services (useful for plugins, documentation, etc.):
import { Injectable, getInjectableMetadata } from '@cryxto/ioc-n-di';
@Injectable({
metadata: {
role: 'service',
layer: 'data',
version: '1.0.0'
}
})
class UserService {}
// Retrieve metadata at runtime
const metadata = getInjectableMetadata(UserService);
console.log(metadata?.metadata); // { role: 'service', layer: 'data', version: '1.0.0' }
console.log(metadata?.scope); // 'singleton'Manually resolve all registered providers in optimal order:
import { Container, Injectable } from '@cryxto/ioc-n-di';
@Injectable()
class ConfigService {}
@Injectable()
class LoggerService {
constructor(private config: ConfigService) {}
}
@Injectable()
class DatabaseService {
constructor(private logger: LoggerService) {}
}
@Injectable()
class AppService {
constructor(
private config: ConfigService,
private logger: LoggerService,
private db: DatabaseService,
) {}
}
const container = Container.createOrGet();
container.register(ConfigService);
container.register(LoggerService);
container.register(DatabaseService);
container.register(AppService);
// Resolve all in optimal order (based on dependency weights)
await container.resolveAll();
// All services are now cached and ready to use
const app = container.getInstance(AppService);Organize related providers into reusable modules using the @Group() decorator:
import { Group, Injectable, Container } from '@cryxto/ioc-n-di';
// Define your services
@Injectable()
class UserRepository {}
@Injectable()
class UserService {
constructor(private repo: UserRepository) {}
}
@Injectable()
class UserController {
constructor(private service: UserService) {}
}
// Group them together
@Group({
providers: [UserRepository, UserService, UserController]
})
class UserModule {}
// Use in bootstrap - the group is automatically flattened
await container.bootstrap([
UserModule, // Expands to UserRepository, UserService, UserController
AppService
]);Groups can contain any provider type (classes, values, factories):
import { MikroORM, EntityManager } from '@mikro-orm/core';
// Factory provider for ORM
const MikroORMProvider = {
provide: MikroORM,
useFactory: async (config) => await MikroORM.init(config),
deps: [ConfigService]
};
// Factory provider for EntityManager
const EntityManagerProvider = {
provide: EntityManager,
useFactory: (orm: MikroORM) => orm.em,
deps: [MikroORM]
};
// Group any provider types together
@Group({
providers: [
ConfigService, // Class
MikroORMProvider, // Factory provider
EntityManagerProvider // Factory provider
]
})
class DatabaseModule {}Groups can contain other groups for hierarchical organization:
@Group({
providers: [ConfigService, LoggerService]
})
class CoreModule {}
@Group({
providers: [UserRepository, UserService]
})
class UserModule {}
@Group({
providers: [CoreModule, UserModule, AppService]
})
class AppModule {}
// All groups are recursively flattened
await container.bootstrap([AppModule]);Use groups in deps to control resolution order without injecting them:
@Group({
providers: [
InvitationController,
UserController,
AuthController
]
})
class ControllersModule {}
// Barrier pattern - ensures all controllers resolve first
const CONTROLLERS_READY = Symbol('CONTROLLERS_READY');
container.register({
provide: CONTROLLERS_READY,
useValue: true,
deps: [ControllersModule] // ControllersModule providers resolve first
});
// App waits for all controllers to be ready
const AppProvider = {
provide: APP,
useFactory: async (apiServer) => createApp(apiServer),
deps: [API_SERVER, CONTROLLERS_READY] // Correct ordering guaranteed
};Add explicit dependencies to control resolution order:
// ClassProvider with explicit deps for weight calculation
container.register({
provide: AppService,
useClass: AppService,
deps: [DatabaseModule, CacheModule] // These resolve first, even if not injected
});
// FactoryProvider deps also affect weight
container.register({
provide: API_SERVER,
useFactory: () => createServer(),
deps: [ControllersModule] // All controllers resolve before server
});
// Groups in deps are automatically flattened
@Group({
providers: [ServiceA, ServiceB],
deps: [ConfigService] // Group itself can have dependencies
})
class FeatureModule {}The main DI container (singleton pattern).
static createOrGet(): Container- Get or create the singleton container instancestatic getContainer(): Container- Deprecated: UsecreateOrGet()insteadregister<T>(provider: Provider<T>): void- Register a providerresolve<T>(token: InjectionToken<T> | Constructor<T>): Promise<T>- Resolve and return an instancebootstrap(providers: Provider[] | { providers: Provider[] }): Promise<this>- Register and resolve all providers at once (NestJS-style)destroy(): Promise<void>- New: Destroy the container and call all onDestroy lifecycle hooksgetInstance<T>(token: InjectionToken<T> | Constructor<T>): T | undefined- Get cached instance synchronouslygetInstanceOrThrow<T>(token: InjectionToken<T> | Constructor<T>): T- Get cached instance or throwresolveAll(): Promise<Map>- Resolve all registered providers in optimal orderclear(): void- Clear all providers and instances (useful for testing)setLogLevel(level: LogLevel): void- New: Set the logging level (OFF, MINIMAL, or VERBOSE)getLogLevel(): LogLevel- New: Get the current logging levelgetDependencyGraph(): Map- Get dependency graph for visualizationcalculateWeight(token): number- Calculate dependency weight for a token
@Injectable(options?)- Mark a class as injectable with optional metadata- Options:
{ scope?: 'singleton', metadata?: Record<string, unknown> }
- Options:
@Inject(token)- Specify injection token for a constructor parameter@Lazy(token)- Inject a lazy reference to handle circular dependencies@Group(options)- New: Group related providers together into a module- Options:
{ providers?: Provider[], deps?: InjectionToken[] }
- Options:
getInjectableMetadata(constructor)- Retrieve metadata stored by@Injectable()decoratorgetGroupMetadata(constructor)- New: Retrieve metadata stored by@Group()decoratorisGroup(target)- New: Check if a class is decorated with@Group()
// Class Provider
{
provide: InjectionToken,
useClass: Constructor,
deps?: InjectionToken[], // Optional: for weight calculation and ordering
onInit?: (instance) => void | Promise<void>,
onDestroy?: (instance) => void | Promise<void> // New: cleanup hook
}
// Value Provider
{
provide: InjectionToken,
useValue: any
}
// Factory Provider
{
provide: InjectionToken,
useFactory: (...args) => any,
deps?: InjectionToken[], // Dependencies injected into factory + affects weight
onInit?: (instance) => void | Promise<void>,
onDestroy?: (instance) => void | Promise<void> // New: cleanup hook
}
// Group (created with @Group decorator)
@Group({
providers?: Provider[], // Providers to group together
deps?: InjectionToken[] // Dependencies for weight calculation
})
class ModuleName {}
// Or just a plain Constructor
ConstructorWrapper for lazy dependency injection.
get value(): T- Get the resolved instance (throws if not resolved)get(): T- Same asvaluetryGetValue(): T | undefined- Try to get the instance without throwingisResolved(): boolean- Check if the instance has been resolvedreset(): void- Clear the cached instance (for testing)
const graph = container.getDependencyGraph();
for (const [service, info] of graph.entries()) {
console.log(`${service} (weight: ${info.weight})`);
console.log(` depends on: ${info.dependencies.join(', ')}`);
}// String tokens
container.register({
provide: 'API_URL',
useValue: 'https://api.example.com',
});
// Symbol tokens (recommended)
const API_URL = Symbol('API_URL');
container.register({
provide: API_URL,
useValue: 'https://api.example.com',
});import { Container, LogLevel } from '@cryxto/ioc-n-di';
describe('MyService', () => {
let container: Container;
beforeEach(() => {
container = Container.createOrGet();
container.clear(); // Clear between tests
container.setLogLevel(LogLevel.OFF); // Disable logging during tests
});
it('should inject dependencies', async () => {
container.register(MockDatabase);
container.register(MyService);
const service = await container.resolve(MyService);
expect(service).toBeDefined();
});
// Test lifecycle hooks
it('should call lifecycle hooks', async () => {
const lifecycleCalls: string[] = [];
class ServiceWithLifecycle implements OnInit, OnDestroy {
async onInit() {
lifecycleCalls.push('init');
}
async onDestroy() {
lifecycleCalls.push('destroy');
}
}
container.register(ServiceWithLifecycle);
await container.resolve(ServiceWithLifecycle);
expect(lifecycleCalls).toContain('init');
await container.destroy();
expect(lifecycleCalls).toContain('destroy');
});
});- Registration: Register classes, values, or factories with the container
- Resolution: The container analyzes constructor parameters using TypeScript metadata
- Dependency Graph: Builds a dependency graph and calculates optimal resolution order
- Instantiation: Creates instances in the correct order, injecting dependencies
- Caching: All instances are cached as singletons
- Lifecycle:
- Calls
onInithooks after instantiation if provided - Calls
onDestroyhooks during cleanup whencontainer.destroy()is invoked
- Calls
The container detects circular dependencies and throws an error by default. Use @Lazy() decorator to break circular chains:
// ❌ This will throw an error
@Injectable()
class A {
constructor(private b: B) {}
}
@Injectable()
class B {
constructor(private a: A) {} // Circular!
}
// ✅ This works
@Injectable()
class A {
constructor(@Lazy(B) private b: LazyRef<B>) {}
}
@Injectable()
class B {
constructor(@Lazy(A) private a: LazyRef<A>) {}
}// InversifyJS
@injectable()
class MyService {
constructor(@inject(TYPES.Database) private db: Database) {}
}
// ioc-n-di
@Injectable()
class MyService {
constructor(@Inject(TYPES.Database) private db: Database) {}
}The API is very similar to NestJS:
// Both work the same way
@Injectable()
class MyService {
constructor(private readonly logger: Logger) {}
}MIT
Please see CONTRIBUTING.md for contribution guidelines.