@apiratorjs/di-container
Version:
A lightweight dependency injection container for JavaScript and TypeScript with powerful features: modular organization with DiModule.create, service tagging for multiple implementations, lazy initialization, automatic circular dependency detection, and m
492 lines (377 loc) • 18.7 kB
Markdown
# /di-container
[](https://www.npmjs.com/package/@apiratorjs/di-container)
[](https://github.com/apiratorjs/di-container/blob/main/LICENSE)
A lightweight dependency injection container for JavaScript and TypeScript with powerful features: modular organization with DiModule.create, service discovery for runtime introspection, service tagging for multiple implementations, lazy initialization, automatic circular dependency detection, and multiple service lifecycles (singleton with both in-place and lazy initialization, request-scoped, transient). Includes built-in async context management, lifecycle hooks (onConstruct/onDispose), and remains completely framework-agnostic for flexible application architecture.
> **Note:** Requires Node.js version **>=16.4.0**
---
## Features
- **Multiple Lifecycles:**
- **Singleton:** One instance per application. By default lazily initialized (created only when requested), but can be configured for eager initialization during DI build step.
- **Request-Scoped:** One instance per request scope using asynchronous context (lazily loaded).
- **Transient:** A new instance on every resolution.
- **Service Tags:** Support for registering multiple implementations of the same service token using tags, enabling flexible service resolution based on context.
- **Lazy Initialization:** Services are only created when requested (default for singletons).
- **Async Context Management:** Leverages [/async-context](https://github.com/apiratorjs/async-context) to
manage request scopes.
- **Circular Dependency Detection:** Automatically detects and reports circular dependencies with detailed chain information through `CircularDependencyError`.
- **Lifecycle Hooks:** Services can implement onConstruct() and onDispose() for custom initialization and cleanup.
- **Singleton:** Supports both onConstruct() and onDispose() hooks.
- **Request-Scoped:** Supports both onConstruct() and onDispose() hooks.
- **Transient:** Supports only onConstruct() hooks.
- **Concurrency Safety:** Designed to avoid race conditions during lazy instantiation.
- **Service Discovery:** Built-in discovery service for introspecting registered services by token, lifetime, tag, or getting all registrations.
- **Modular Organization:** Services can be organized into modules, allowing for better separation of concerns and reusability.
---
## Installation
Install via npm:
```bash
npm install /di-container
```
Or using yarn:
```bash
yarn add /di-container
```
## Usage
### Quick Start
Create and configure your DI container with the DiConfigurator, then build a DiContainer for runtime usage:
```typescript
import { DiConfigurator } from "@apiratorjs/di-container";
import { AsyncContextStore } from "@apiratorjs/async-context";
// Service classes with lifecycle hooks
class DatabaseService {
async onConstruct() { console.log("Database connected"); }
async onDispose() { console.log("Database disconnected"); }
async query(sql: string) { return `Result for: ${sql}`; }
}
class UserService {
constructor(private db: DatabaseService) {}
async getUser(id: string) {
return await this.db.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
// Configure services with different lifecycles and options
const configurator = new DiConfigurator();
// Singleton with eager initialization
configurator.addSingleton("DATABASE", () => new DatabaseService(), { eager: true });
// Scoped service with dependency injection
configurator.addScoped("USER_SERVICE", async (container) => {
const db = await container.resolve("DATABASE");
return new UserService(db);
});
// Transient service
configurator.addTransient("LOGGER", () => ({ log: (msg) => console.log(`[LOG] ${msg}`) }));
// Build container (eagerly initializes singletons)
const container = await configurator.build();
// Use services - scoped services REQUIRE a request scope
await container.runWithNewRequestScope(async (container) => {
const userService = await container.resolve("USER_SERVICE"); // ✅ Works in scope
const logger = await container.resolve("LOGGER");
const user = await userService.getUser("123");
logger.log(`Retrieved user: ${user}`);
}, new AsyncContextStore());
// ❌ This would throw RequestScopeResolutionError:
// await container.resolve("USER_SERVICE"); // Error: scoped service outside scope
// Cleanup when done
await container.dispose();
```
### Core Concepts
**Service Lifecycles:**
- **Singleton**: One instance per application (lazy by default, can be eager)
- **Scoped**: One instance per request scope - **MUST** be used within `runWithNewRequestScope()`, throws `RequestScopeResolutionError` otherwise
- **Transient**: New instance on every resolution
**Important:** Scoped services cannot be resolved outside a request scope:
```typescript
// ✅ Correct usage
await container.runWithNewRequestScope(async (container) => {
const scopedService = await container.resolve("SCOPED_SERVICE"); // Works
}, new AsyncContextStore());
// ❌ This throws RequestScopeResolutionError
const scopedService = await container.resolve("SCOPED_SERVICE"); // Error!
```
**Service Tags (Optional):** Register multiple implementations when needed:
```typescript
configurator.addSingleton("PAYMENT", () => new StripePayment(), undefined, "stripe");
configurator.addSingleton("PAYMENT", () => new PayPalPayment(), undefined, "paypal");
```
**Lifecycle Hooks:** Services can implement `onConstruct()` and `onDispose()` for automatic initialization and cleanup.
## IDiConfigurator Interface
The `IDiConfigurator` is the main interface for configuring dependency injection services. Here are all available methods:
### Service Registration Methods
| Method | Description | Example |
|--------|-------------|---------|
| `addSingleton<T>(token, factory, options?, tag?)` | Register a singleton service | `configurator.addSingleton("DB", () => new Database(), { eager: true })` |
| `addScoped<T>(token, factory, tag?)` | Register a request-scoped service | `configurator.addScoped("USER_CTX", async (cfg) => new UserContext())` |
| `addTransient<T>(token, factory, tag?)` | Register a transient service | `configurator.addTransient("LOGGER", () => new Logger(), "console")` |
| `addModule(module)` | Register a module with multiple services | `configurator.addModule(new DatabaseModule())` |
### Container Management Methods
| Method | Description | Returns |
|--------|-------------|---------|
| `build()` | Build the runtime container | `Promise<DiContainer>` |
| `getDiscoveryService()` | Get discovery service | `DiDiscoveryService` for service introspection |
### Practical Example
```typescript
const configurator = new DiConfigurator();
// Registration - various lifecycles and features
configurator
.addSingleton("CONFIG", () => ({ env: "prod" }), { eager: true })
.addScoped("REQUEST_ID", () => Math.random().toString(36))
.addTransient("LOGGER", () => new ConsoleLogger());
// Build container for runtime usage
const container = await configurator.build();
// Service introspection during configuration
const discovery = configurator.getDiscoveryService();
const eagerServices = discovery.getServicesByLifetime("singleton")
.filter(s => s.singletonOptions?.eager);
console.log("Eager services configured:", eagerServices.length);
// ✅ Use the container for service resolution:
await container.runWithNewRequestScope(async (container) => {
const requestId = await container.resolve("REQUEST_ID"); // Works in scope
const logger = await container.resolve("LOGGER");
console.log(`Processing request: ${requestId}`);
}, new AsyncContextStore());
await container.dispose();
```
## IDiContainer Interface
The `IDiContainer` is the runtime interface for resolving services after building your DI configuration. It provides a clean, read-only interface focused on service resolution and request scope management.
### Service Resolution Methods
| Method | Description | Returns |
|--------|-------------|---------|
| `resolve<T>(token, tag?)` | Resolve a service (optional) | `Promise<T \| undefined>` |
| `resolveRequired<T>(token, tag?)` | Resolve a service (throws if not found) | `Promise<T>` |
| `resolveAll<T>(token)` | Resolve all implementations | `Promise<T[]>` |
| `resolveTagged<T>(tag)` | Resolve first service with tag | `Promise<T \| undefined>` |
| `resolveTaggedRequired<T>(tag)` | Resolve service with tag (throws if not found) | `Promise<T>` |
| `resolveAllTagged<T>(tag)` | Resolve all services with tag | `Promise<T[]>` |
### Runtime Management Methods
| Method | Description | Returns |
|--------|-------------|---------|
| `runWithNewRequestScope(callback, initialStore)` | Execute code in request scope (**Required** for scoped services) | `Promise<void>` |
| `isInRequestScopeContext()` | Check if in request scope | `boolean` |
| `dispose()` | Dispose all services (cleanup singletons and scoped services) | `Promise<void>` |
| `getDiscoveryService()` | Get discovery service for introspection | `DiDiscoveryService` |
### Key Differences from IDiConfigurator
**IDiContainer is for runtime usage only:**
- ✅ **Service Resolution**: All resolve methods available
- ✅ **Request Scope Management**: Required for scoped services
- ✅ **Discovery**: Service introspection and health checks
- ✅ **Cleanup**: Proper disposal of resources
- ❌ **No Service Registration**: Cannot add new services
- ❌ **No Building**: Already built and ready to use
### Practical Usage Example
```typescript
const configurator = new DiConfigurator();
configurator
.addSingleton("DATABASE", () => new DatabaseService(), { eager: true })
.addScoped("USER_CTX", () => ({ userId: "user123" }))
.addTransient("LOGGER", () => new ConsoleLogger());
// Build creates the runtime container
const container = await configurator.build();
// ✅ Singleton and transient services work anywhere
const logger = await container.resolve("LOGGER");
logger?.log("Application started");
// ✅ Scoped services MUST be used within request scope
await container.runWithNewRequestScope(async (container) => {
const userCtx = await container.resolveRequired("USER_CTX"); // Works in scope
const database = await container.resolve("DATABASE");
console.log(`Processing for user: ${userCtx.userId}`);
}, new AsyncContextStore());
// ❌ This throws RequestScopeResolutionError:
// const userCtx = await container.resolve("USER_CTX"); // Error!
// Health check using discovery
const discovery = container.getDiscoveryService();
const eagerSingletons = discovery.getServicesByLifetime("singleton")
.filter(s => s.singletonOptions?.eager);
console.log("Eager services initialized:",
eagerSingletons.every(s => s.isResolved)
);
// Cleanup when shutting down
await container.dispose();
```
### Service Discovery
Query and introspect registered services for debugging, monitoring, and dynamic resolution. The discovery service returns `IServiceRegistration` objects with detailed information about each service.
#### IServiceRegistration Interface
Each service registration returned by the discovery service contains:
| Property | Type | Description |
|----------|------|-------------|
| `token` | `TServiceToken` | The service token (string, symbol, or class) |
| `tokenType` | `"string" \| "symbol" \| "class"` | Type of the token |
| `lifetime` | `"singleton" \| "scoped" \| "transient"` | Service lifetime |
| `tag` | `string` | Service tag (defaults to "default") |
| `isResolved` | `boolean` | Whether service instance has been created |
| `singletonOptions` | `ISingletonOptions?` | Options for singleton services |
| `metatype` | `TClassType?` | Class constructor if token is a class |
#### Discovery Service Methods
| Method | Description | Returns |
|--------|-------------|---------|
| `getAll()` | Get all registered services | `IServiceRegistration[]` |
| `getServicesByTag(tag)` | Get services by tag | `IServiceRegistration[]` |
| `getServicesByServiceToken(token)` | Get services by token | `IServiceRegistration[]` |
| `getServicesByLifetime(lifetime)` | Get services by lifetime | `IServiceRegistration[]` |
#### IServiceRegistration Methods
| Method | Description |
|--------|-------------|
| `getInstance()` | Get the current service instance (if resolved) |
#### Discovery Example
```typescript
const configurator = new DiConfigurator();
// Register services
configurator.addSingleton("DATABASE", () => new DatabaseService(), { eager: true });
configurator.addScoped("USER_SERVICE", () => new UserService());
configurator.addTransient("LOGGER", () => new LoggerService());
const discovery = configurator.getDiscoveryService();
// Query by different criteria
const allServices = discovery.getAll();
const singletons = discovery.getServicesByLifetime("singleton");
const databaseServices = discovery.getServicesByServiceToken("DATABASE");
// Work with service registrations
const databaseReg = databaseServices[0];
console.log(`Database service token: ${databaseReg.token}`);
console.log(`Token type: ${databaseReg.tokenType}`);
console.log(`Is eager: ${databaseReg.singletonOptions?.eager}`);
console.log(`Is resolved: ${databaseReg.isResolved}`);
// Health check for eager services
const eagerServices = discovery.getServicesByLifetime("singleton")
.filter(s => s.singletonOptions?.eager)
.map(s => ({ token: s.token, resolved: s.isResolved }));
console.log("Eager services status:", eagerServices);
// Service inventory
console.table(discovery.getAll().map(s => ({
Token: s.token.toString(),
Type: s.tokenType,
Lifetime: s.lifetime,
Tag: s.tag,
Resolved: s.isResolved,
Eager: s.singletonOptions?.eager || false
})));
```
### Advanced Features
**Circular Dependency Detection:** Automatic detection with detailed error chains:
```typescript
// This creates a circular dependency
configurator.addSingleton("ServiceA", async (container) => {
await container.resolve("ServiceB"); // Will detect the cycle
return new ServiceA();
});
configurator.addSingleton("ServiceB", async (container) => {
await container.resolve("ServiceA");
return new ServiceB();
});
// Throws CircularDependencyError with chain: ["ServiceA", "ServiceB", "ServiceA"]
```
**Complete Application Example:**
```typescript
import { DiConfigurator, IOnConstruct, IOnDispose } from "@apiratorjs/di-container";
import { AsyncContextStore } from "@apiratorjs/async-context";
// Service classes
class Config {
public readonly dbUrl = "mongodb://localhost";
}
class Database implements IOnConstruct, IOnDispose {
constructor(private config: Config) {}
async onConstruct() { console.log(`Connected to ${this.config.dbUrl}`); }
async onDispose() { console.log("Database disconnected"); }
async findUser(email: string) { return { email, id: Math.random() }; }
}
class UserService {
constructor(private db: Database) {}
async getUser(email: string) {
return await this.db.findUser(email);
}
}
const configurator = new DiConfigurator();
configurator
.addSingleton("CONFIG", () => new Config(), { eager: true })
.addSingleton("DATABASE", async (container) => {
const config = await container.resolve("CONFIG");
return new Database(config);
})
.addScoped("USER_SERVICE", async (container) => {
const db = await container.resolve("DATABASE");
return new UserService(db);
});
// Usage - scoped services MUST be used within request scope
const container = await configurator.build();
await container.runWithNewRequestScope(async (container) => {
const userService = await container.resolve("USER_SERVICE"); // ✅ Works in scope
const user = await userService.getUser("user@example.com");
console.log("Found user:", user);
}, new AsyncContextStore());
// ❌ This would throw RequestScopeResolutionError:
// const userService = await container.resolve("USER_SERVICE"); // Error!
await container.dispose(); // Cleanup
```
## Modules
Organize related services into reusable modules for better code organization:
### Class-based Modules
```typescript
class DatabaseModule implements IDiModule {
register(configurator: DiConfigurator): void {
configurator.addSingleton("DATABASE", () => new DatabaseConnection());
configurator.addScoped("TRANSACTION", async (cfg) => {
const db = await cfg.resolve("DATABASE");
return new TransactionManager(db);
});
}
}
```
### Declarative Modules with DiModule.create
```typescript
// Create modular service definitions
const LoggingModule = DiModule.create({
providers: [
{ token: "LOGGER", useFactory: () => new ConsoleLogger(), lifetime: "singleton" }
]
});
const DataModule = DiModule.create({
imports: [LoggingModule],
providers: [
{
token: "DATABASE",
useFactory: () => new Database(),
lifetime: "singleton",
singletonOptions: { eager: true }
},
{
token: "USER_REPO",
useFactory: async (container) => {
const db = await container.resolve("DATABASE");
const logger = await container.resolve("LOGGER");
return new UserRepository(db, logger);
},
lifetime: "scoped"
}
]
});
const AppModule = DiModule.create({
imports: [DataModule],
providers: [
{
token: "USER_SERVICE",
useFactory: async (container) => {
const repo = await container.resolve("USER_REPO");
return new UserService(repo);
},
lifetime: "scoped",
tag: "primary" // Optional tag support
}
]
});
// Register and use - remember scoped services need request scope!
const configurator = new DiConfigurator();
configurator.addModule(AppModule);
const container = await configurator.build();
await container.runWithNewRequestScope(async (container) => {
const userService = await container.resolve("USER_SERVICE"); // ✅ Works in scope
// Or resolve by tag:
const primaryUserService = await container.resolveTagged("primary");
}, new AsyncContextStore());
```
**Module Features:**
- **Imports**: Import other modules to establish dependencies
- **Providers**: Define services with tokens, factories, lifecycles, optional singleton options, and optional tags
- **Hierarchical**: Create nested module structures
- **Registration Order**: First registration wins for same token+tag combinations
- **Tag Support**: Multiple implementations can be registered with different tags
### Contributing
Contributions, issues, and feature requests are welcome!
Please open an issue or submit a pull request on [GitHub](https://github.com/apiratorjs/di-container).