Container
Goal: Manage the lifecycle of dependencies, resolve them when requested, and maintain scoped instances.
The Container is the central component of the IoC system. Understanding containers is essential for effective dependency injection.
Scopes
Scopes allow you to create isolated dependency contexts. In web applications, you typically have:
- Application scope - Singleton services like database pools, configuration
- Request scope - Per-request services like session, current user context
- Transaction scope - Database transaction boundaries
Each scope can have tags that control which providers are available, preventing accidental access to request-specific data from singletons.
Request Scope Pattern (Express.js)
The most common pattern is creating a request-scoped container for each HTTP request:
// Middleware: Create request scope for each request
app.use((req, res, next) => {
// Create isolated scope for this request
req.container = appContainer.createScope({ tags: ['request'] });
// Clean up when response finishes
res.on('finish', () => req.container.dispose());
next();
});
// SessionService is isolated per request - no data leaks between users
const session = req.container.resolve<SessionService>('ISessionService');
session.setCurrentUser(authenticatedUserId);// Middleware: Create request scope for each request
app.use((req, res, next) => {
// Create isolated scope for this request
req.container = appContainer.createScope({ tags: ['request'] });
// Clean up when response finishes
res.on('finish', () => req.container.dispose());
next();
});
// SessionService is isolated per request - no data leaks between users
const session = req.container.resolve<SessionService>('ISessionService');
session.setCurrentUser(authenticatedUserId);Tagged Scopes
Tags allow you to control which providers are available in which scopes. Services can be restricted to specific scope types for security and architectural clarity.
import 'reflect-metadata';
import {
bindTo,
Container,
DependencyNotFoundError,
type IContainer,
inject,
register,
Registration as R,
scope,
select,
singleton,
} from 'ts-ioc-container';
/**
* User Management Domain - Request Scopes
*
* In web applications, each HTTP request typically gets its own scope.
* This allows request-specific data (current user, request ID, etc.)
* to be isolated between concurrent requests.
*
* Scope hierarchy:
* Application (singleton services)
* └── Request (per-request services)
* └── Transaction (database transaction boundary)
*/
// SessionService is only available in request scope - not at application level
@register(bindTo('ISessionService'), scope((s) => s.hasTag('request')), singleton())
class SessionService {
private userId: string | null = null;
setCurrentUser(userId: string) {
this.userId = userId;
}
getCurrentUserId(): string | null {
return this.userId;
}
}
describe('Scopes', function () {
it('should isolate request-scoped services', function () {
// Application container - lives for entire app lifetime
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(SessionService));
// Simulate two concurrent HTTP requests
const request1Scope = appContainer.createScope({ tags: ['request'] });
const request2Scope = appContainer.createScope({ tags: ['request'] });
// Each request has its own SessionService instance
const session1 = request1Scope.resolve<SessionService>('ISessionService');
const session2 = request2Scope.resolve<SessionService>('ISessionService');
session1.setCurrentUser('user-1');
session2.setCurrentUser('user-2');
// Sessions are isolated - user data doesn't leak between requests
expect(session1.getCurrentUserId()).toBe('user-1');
expect(session2.getCurrentUserId()).toBe('user-2');
expect(session1).not.toBe(session2);
// SessionService is NOT available at application level (security!)
expect(() => appContainer.resolve('ISessionService')).toThrow(DependencyNotFoundError);
});
it('should create child scopes for transactions', function () {
const appContainer = new Container({ tags: ['application'] });
// RequestHandler can create a transaction scope for database operations
class RequestHandler {
constructor(@inject(select.scope.create({ tags: ['transaction'] })) public transactionScope: IContainer) {}
executeInTransaction(): boolean {
// Transaction scope inherits from request scope
// Database operations can be rolled back together
return this.transactionScope.hasTag('transaction');
}
}
const handler = appContainer.resolve(RequestHandler);
expect(handler.transactionScope).not.toBe(appContainer);
expect(handler.transactionScope.hasTag('transaction')).toBe(true);
expect(handler.executeInTransaction()).toBe(true);
});
});
import 'reflect-metadata';
import {
bindTo,
Container,
DependencyNotFoundError,
type IContainer,
inject,
register,
Registration as R,
scope,
select,
singleton,
} from 'ts-ioc-container';
/**
* User Management Domain - Request Scopes
*
* In web applications, each HTTP request typically gets its own scope.
* This allows request-specific data (current user, request ID, etc.)
* to be isolated between concurrent requests.
*
* Scope hierarchy:
* Application (singleton services)
* └── Request (per-request services)
* └── Transaction (database transaction boundary)
*/
// SessionService is only available in request scope - not at application level
@register(bindTo('ISessionService'), scope((s) => s.hasTag('request')), singleton())
class SessionService {
private userId: string | null = null;
setCurrentUser(userId: string) {
this.userId = userId;
}
getCurrentUserId(): string | null {
return this.userId;
}
}
describe('Scopes', function () {
it('should isolate request-scoped services', function () {
// Application container - lives for entire app lifetime
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(SessionService));
// Simulate two concurrent HTTP requests
const request1Scope = appContainer.createScope({ tags: ['request'] });
const request2Scope = appContainer.createScope({ tags: ['request'] });
// Each request has its own SessionService instance
const session1 = request1Scope.resolve<SessionService>('ISessionService');
const session2 = request2Scope.resolve<SessionService>('ISessionService');
session1.setCurrentUser('user-1');
session2.setCurrentUser('user-2');
// Sessions are isolated - user data doesn't leak between requests
expect(session1.getCurrentUserId()).toBe('user-1');
expect(session2.getCurrentUserId()).toBe('user-2');
expect(session1).not.toBe(session2);
// SessionService is NOT available at application level (security!)
expect(() => appContainer.resolve('ISessionService')).toThrow(DependencyNotFoundError);
});
it('should create child scopes for transactions', function () {
const appContainer = new Container({ tags: ['application'] });
// RequestHandler can create a transaction scope for database operations
class RequestHandler {
constructor(@inject(select.scope.create({ tags: ['transaction'] })) public transactionScope: IContainer) {}
executeInTransaction(): boolean {
// Transaction scope inherits from request scope
// Database operations can be rolled back together
return this.transactionScope.hasTag('transaction');
}
}
const handler = appContainer.resolve(RequestHandler);
expect(handler.transactionScope).not.toBe(appContainer);
expect(handler.transactionScope.hasTag('transaction')).toBe(true);
expect(handler.executeInTransaction()).toBe(true);
});
});
Scope Hierarchy
Containers can create child scopes, forming a tree structure. Each scope maintains its own provider registry and instance cache, but can fall back to parent scopes for resolution.
tags: 'root'"] Request1["Request Scope 1
tags: 'request', 'user:123'"] Request2["Request Scope 2
tags: 'request', 'user:456'"] Feature1["Feature Scope 1
tags: 'feature:admin'"] Feature2["Feature Scope 2
tags: 'feature:user'"] Root -->|createScope| Request1 Root -->|createScope| Request2 Request1 -->|createScope| Feature1 Request2 -->|createScope| Feature2 style Root fill:#0366d6,color:#fff style Request1 fill:#28a745,color:#fff style Request2 fill:#28a745,color:#fff style Feature1 fill:#ffc107,color:#000 style Feature2 fill:#ffc107,color:#000
Creating a Scope
When you create a new scope using createScope(), the container follows a specific process to set up the child scope. The new scope maintains a reference to its parent, allowing it to access parent registrations while maintaining its own isolated instance cache.
with parent reference]) --> CopyHooks[Copy hooks
onConstruct, onDispose] CopyHooks --> GetRegs[Get all registrations
parent + current scope] GetRegs --> Apply[Apply registration to new scope] Apply --> AddScope[Add scope to
scopes list] AddScope --> Return([Return new scope]) classDef startEnd fill:#64748b,stroke:#475569,color:#fff classDef process fill:#3b82f6,stroke:#2563eb,color:#fff classDef action fill:#8b5cf6,stroke:#7c3aed,color:#fff class Create,Return startEnd class CopyHooks,GetRegs,AddScope process class Apply action
The scope creation process ensures that lifecycle hooks are properly inherited, all relevant registrations are available, and the new scope is properly registered in the parent’s scope list for management and disposal.
Scope Resolution Strategy
When resolving a dependency, the container follows this strategy:
- Check if provider exists in current scope
- Check if provider has access in current scope (scope access rule)
- If not found, recursively check parent scopes
- If found in parent, check access from current scope
- Throw
DependencyNotFoundErrorif not found anywhere
Dynamic Tag Management
The addTags() method allows you to dynamically add tags to a container after it’s been created. This is particularly useful for:
- Environment-based configuration - Add tags based on
NODE_ENVor other runtime configuration - Feature flags - Enable/disable features by adding tags conditionally
- Progressive configuration - Add tags incrementally as the application initializes
- Testing - Configure containers differently for different test scenarios
Important: Tags must be added before registrations are applied. Scope matching happens at registration time, so adding tags after registration won’t retroactively make providers available.
import { bindTo, Container, register, Registration as R, scope } from 'ts-ioc-container';
describe('addTags', () => {
it('should dynamically add tags to enable environment-based registration', () => {
@register(bindTo('logger'), scope((s) => s.hasTag('development')))
class ConsoleLogger {
log(message: string) {
console.log(`[DEV] ${message}`);
}
}
@register(bindTo('logger'), scope((s) => s.hasTag('production')))
class FileLogger {
log(message: string) {
console.log(`[PROD] ${message}`);
}
}
// Create container and configure for environment
const container = new Container();
const environment = 'development';
container.addTags(environment); // Add tag dynamically based on environment
// Register services after tag is set
container.addRegistration(R.fromClass(ConsoleLogger)).addRegistration(R.fromClass(FileLogger));
// Resolve logger - gets ConsoleLogger because 'development' tag was added
const logger = container.resolve<ConsoleLogger>('logger');
expect(logger).toBeInstanceOf(ConsoleLogger);
});
it('should add multiple tags for feature-based configuration', () => {
@register(bindTo('premiumFeature'), scope((s) => s.hasTag('premium')))
class PremiumFeature {}
@register(bindTo('betaFeature'), scope((s) => s.hasTag('beta')))
class BetaFeature {}
const container = new Container();
// Add multiple tags at once
container.addTags('premium', 'beta', 'experimental');
// Verify all tags are present
expect(container.hasTag('premium')).toBe(true);
expect(container.hasTag('beta')).toBe(true);
expect(container.hasTag('experimental')).toBe(true);
// Register features after tags are added
container.addRegistration(R.fromClass(PremiumFeature)).addRegistration(R.fromClass(BetaFeature));
// Both features are available because container has both tags
expect(container.resolve('premiumFeature')).toBeInstanceOf(PremiumFeature);
expect(container.resolve('betaFeature')).toBeInstanceOf(BetaFeature);
});
it('should affect child scope creation', () => {
@register(bindTo('service'), scope((s) => s.hasTag('api')))
class ApiService {
handleRequest() {
return 'API response';
}
}
const appContainer = new Container();
// Add tag to parent
appContainer.addTags('api');
appContainer.addRegistration(R.fromClass(ApiService));
// Create child scopes - they inherit parent's registrations
const requestScope1 = appContainer.createScope({ tags: ['request'] });
const requestScope2 = appContainer.createScope({ tags: ['request'] });
// Both scopes can access the ApiService from parent
expect(requestScope1.resolve<ApiService>('service').handleRequest()).toBe('API response');
expect(requestScope2.resolve<ApiService>('service').handleRequest()).toBe('API response');
});
it('should enable incremental tag addition', () => {
const container = new Container();
// Start with basic tags
container.addTags('application');
expect(container.hasTag('application')).toBe(true);
// Add more tags as needed
container.addTags('monitoring', 'logging');
expect(container.hasTag('monitoring')).toBe(true);
expect(container.hasTag('logging')).toBe(true);
// All tags are retained
expect(container.hasTag('application')).toBe(true);
});
});
import { bindTo, Container, register, Registration as R, scope } from 'ts-ioc-container';
describe('addTags', () => {
it('should dynamically add tags to enable environment-based registration', () => {
@register(bindTo('logger'), scope((s) => s.hasTag('development')))
class ConsoleLogger {
log(message: string) {
console.log(`[DEV] ${message}`);
}
}
@register(bindTo('logger'), scope((s) => s.hasTag('production')))
class FileLogger {
log(message: string) {
console.log(`[PROD] ${message}`);
}
}
// Create container and configure for environment
const container = new Container();
const environment = 'development';
container.addTags(environment); // Add tag dynamically based on environment
// Register services after tag is set
container.addRegistration(R.fromClass(ConsoleLogger)).addRegistration(R.fromClass(FileLogger));
// Resolve logger - gets ConsoleLogger because 'development' tag was added
const logger = container.resolve<ConsoleLogger>('logger');
expect(logger).toBeInstanceOf(ConsoleLogger);
});
it('should add multiple tags for feature-based configuration', () => {
@register(bindTo('premiumFeature'), scope((s) => s.hasTag('premium')))
class PremiumFeature {}
@register(bindTo('betaFeature'), scope((s) => s.hasTag('beta')))
class BetaFeature {}
const container = new Container();
// Add multiple tags at once
container.addTags('premium', 'beta', 'experimental');
// Verify all tags are present
expect(container.hasTag('premium')).toBe(true);
expect(container.hasTag('beta')).toBe(true);
expect(container.hasTag('experimental')).toBe(true);
// Register features after tags are added
container.addRegistration(R.fromClass(PremiumFeature)).addRegistration(R.fromClass(BetaFeature));
// Both features are available because container has both tags
expect(container.resolve('premiumFeature')).toBeInstanceOf(PremiumFeature);
expect(container.resolve('betaFeature')).toBeInstanceOf(BetaFeature);
});
it('should affect child scope creation', () => {
@register(bindTo('service'), scope((s) => s.hasTag('api')))
class ApiService {
handleRequest() {
return 'API response';
}
}
const appContainer = new Container();
// Add tag to parent
appContainer.addTags('api');
appContainer.addRegistration(R.fromClass(ApiService));
// Create child scopes - they inherit parent's registrations
const requestScope1 = appContainer.createScope({ tags: ['request'] });
const requestScope2 = appContainer.createScope({ tags: ['request'] });
// Both scopes can access the ApiService from parent
expect(requestScope1.resolve<ApiService>('service').handleRequest()).toBe('API response');
expect(requestScope2.resolve<ApiService>('service').handleRequest()).toBe('API response');
});
it('should enable incremental tag addition', () => {
const container = new Container();
// Start with basic tags
container.addTags('application');
expect(container.hasTag('application')).toBe(true);
// Add more tags as needed
container.addTags('monitoring', 'logging');
expect(container.hasTag('monitoring')).toBe(true);
expect(container.hasTag('logging')).toBe(true);
// All tags are retained
expect(container.hasTag('application')).toBe(true);
});
});
Usage Pattern
// 1. Create container
const container = new Container();
// 2. Add tags based on configuration
container.addTags('production', 'api', 'v2');
// 3. Apply registrations (scope matching happens here)
container.addRegistration(R.fromClass(ProductionService));
Cross-Scope Dependency Injection
Scope System Rule (Similar to JavaScript Scoping)
The container scope system works like JavaScript’s lexical scoping:
- Inner scopes can access outer scopes: Dependencies in child scopes can access dependencies from parent scopes ✅
- Outer scopes cannot access inner scopes: Dependencies in parent scopes cannot access dependencies from child scopes ❌
// JavaScript analogy
function parent() {
const parentVar = 'accessible from child';
function child() {
const childVar = 'NOT accessible from parent';
console.log(parentVar); // ✅ Works - child can access parent
}
console.log(childVar); // ❌ ReferenceError - parent cannot access child
}// JavaScript analogy
function parent() {
const parentVar = 'accessible from child';
function child() {
const childVar = 'NOT accessible from parent';
console.log(parentVar); // ✅ Works - child can access parent
}
console.log(childVar); // ❌ ReferenceError - parent cannot access child
}Important: A service’s resolution context is determined by where it is registered, not where it’s requested from. This creates an important limitation when parent-scoped services try to inject child-scoped dependencies.
tag: 'parent'] C1[Child Scope
tag: 'child'] SA1["Service A
scope: parent only
needs Service B"] SB1["Service B
scope: child only"] P1 -.contains.-> SA1 C1 -.contains.-> SB1 P1 -->|parent of| C1 R1_1["1️⃣ Resolve Service A"] R1_2["2️⃣ A's context = Parent"] R1_3["3️⃣ A needs B"] R1_4["4️⃣ Search: Parent → EmptyContainer"] R1_5["❌ B not found
DependencyNotFoundError"] R1_1 --> R1_2 --> R1_3 --> R1_4 --> R1_5 style P1 fill:#3d2a1f,stroke:#ff6e40,stroke-width:2px,color:#fff style C1 fill:#1a2332,stroke:#4fc3f7,stroke-width:2px,color:#fff style SA1 fill:#4a2622,stroke:#ff5252,stroke-width:2px,color:#fff style SB1 fill:#1e3a5f,stroke:#42a5f5,stroke-width:2px,color:#fff style R1_5 fill:#5d1f1f,stroke:#ff5252,stroke-width:3px,color:#ffcdd2 end subgraph Scenario2["✅ Child → Parent (WORKS)"] direction TB P2[Parent Scope
tag: 'parent'] C2[Child Scope
tag: 'child'] SA2["Service A
scope: parent only"] SB2["Service B
scope: child only
needs Service A"] P2 -.contains.-> SA2 C2 -.contains.-> SB2 P2 -->|parent of| C2 R2_1["1️⃣ Resolve Service B"] R2_2["2️⃣ B's context = Child"] R2_3["3️⃣ B needs A"] R2_4["4️⃣ Search: Child → Parent"] R2_5["✅ A found in Parent
Success"] R2_1 --> R2_2 --> R2_3 --> R2_4 --> R2_5 style P2 fill:#3d2a1f,stroke:#ff6e40,stroke-width:2px,color:#fff style C2 fill:#1a2332,stroke:#4fc3f7,stroke-width:2px,color:#fff style SA2 fill:#4a2622,stroke:#ff5252,stroke-width:2px,color:#fff style SB2 fill:#1e3a5f,stroke:#42a5f5,stroke-width:2px,color:#fff style R2_5 fill:#1f3d1f,stroke:#66bb6a,stroke-width:3px,color:#a5d6a7 end style Scenario1 fill:#1a1a1a,stroke:#f44336,stroke-width:3px,color:#fff style Scenario2 fill:#1a1a1a,stroke:#4caf50,stroke-width:3px,color:#fff
Resolution Context Principle
When a service is registered with scope((c) => c.hasTag('parent')), it always resolves from the parent container—even if the service is requested through a child scope.
Parent → Child Dependency (❌ FAILS)
Scenario: Parent-scoped service A tries to inject child-scoped service B
// Service A - registered only for parent scope
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent'))
)
class ServiceA {
constructor(@inject('ServiceB') serviceB: ServiceB) {
// ❌ Throws DependencyNotFoundError
}
}
// Service B - registered for child scope
@register(
bindTo('ServiceB'),
scope((c) => c.hasTag('child'))
)
class ServiceB { }// Service A - registered only for parent scope
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent'))
)
class ServiceA {
constructor(@inject('ServiceB') serviceB: ServiceB) {
// ❌ Throws DependencyNotFoundError
}
}
// Service B - registered for child scope
@register(
bindTo('ServiceB'),
scope((c) => c.hasTag('child'))
)
class ServiceB { }Resolution flow:
- A is registered for parent scope only
- A’s resolution context = parent container
- A looks for B in parent scope → parent’s parent (EmptyContainer)
- B only exists in child scope (invisible to parent)
- Result:
DependencyNotFoundErrorthrown
Even if A is requested from a child scope, A still resolves from the parent container internally.
Child → Parent Dependency (✅ WORKS)
Scenario: Child-scoped service B tries to inject parent-scoped service A
// Service A - registered for parent scope
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent'))
)
class ServiceA { }
// Service B - registered for child scope
@register(
bindTo('ServiceB'),
scope((c) => c.hasTag('child'))
)
class ServiceB {
constructor(@inject('ServiceA') serviceA: ServiceA) {
// ✅ Works - A is found via upward cascade
}
}// Service A - registered for parent scope
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent'))
)
class ServiceA { }
// Service B - registered for child scope
@register(
bindTo('ServiceB'),
scope((c) => c.hasTag('child'))
)
class ServiceB {
constructor(@inject('ServiceA') serviceA: ServiceA) {
// ✅ Works - A is found via upward cascade
}
}Resolution flow:
- B is registered for child scope
- B’s resolution context = child container
- B looks for A: child scope → parent scope
- A found in parent scope (accessible via cascade)
- Result: Success
Summary
- Upward resolution (child → parent): ✅ Works via cascade
- Downward resolution (parent → child): ❌ Fails - parents can’t see children
Workarounds
If you need a parent-scoped service to access child-scoped dependencies, consider these options:
// Option 1: Register for both scopes
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent') || c.hasTag('child'))
)
class ServiceA { }
// Option 2: Use scopeAccess for visibility control instead
@register(
bindTo('ServiceA'),
// Available in all scopes
scopeAccess(({ invocationScope }) =>
invocationScope.hasTag('parent') || invocationScope.hasTag('child')
)
)
class ServiceA { }// Option 1: Register for both scopes
@register(
bindTo('ServiceA'),
scope((c) => c.hasTag('parent') || c.hasTag('child'))
)
class ServiceA { }
// Option 2: Use scopeAccess for visibility control instead
@register(
bindTo('ServiceA'),
// Available in all scopes
scopeAccess(({ invocationScope }) =>
invocationScope.hasTag('parent') || invocationScope.hasTag('child')
)
)
class ServiceA { }Dependency Resolution Flow
The following sequence diagram illustrates how a dependency is resolved from the container:
constructor?} CheckConstructor -->|Yes| Injector[Resolve constructor
from Injector] CheckConstructor -->|No| FindProvider[Find provider
by string or symbol] FindProvider --> Container{Provider exists
and has access
to current scope?} Container -->|Yes| Provider[Resolve dependency
from provider] Container -->|No| ResolveParent[Resolve dependency
from parent scope] style Start fill:#4CAF50,stroke:#2E7D32,stroke-width:3px,color:#fff style CheckConstructor fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#fff style Container fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#fff style Injector fill:#2196F3,stroke:#1565C0,stroke-width:2px,color:#fff style FindProvider fill:#9C27B0,stroke:#6A1B9A,stroke-width:2px,color:#fff style Provider fill:#00BCD4,stroke:#00838F,stroke-width:2px,color:#fff style ResolveParent fill:#FF5722,stroke:#BF360C,stroke-width:2px,color:#fff
Instance Management
The container tracks all instances it creates, allowing you to query and manage them. This is particularly useful for cleanup operations, debugging, or implementing custom lifecycle management.
Querying Instances
import { bindTo, Container, inject, register, Registration as R, select } from 'ts-ioc-container';
/**
* User Management Domain - Instance Collection
*
* Sometimes you need access to all instances of a certain type:
* - Collect all active database connections for health checks
* - Gather all loggers to flush buffers before shutdown
* - Find all request handlers for metrics collection
*
* The `select.instances()` token resolves all created instances,
* optionally filtered by a predicate function.
*/
describe('Instances', function () {
@register(bindTo('ILogger'))
class Logger {}
it('should collect instances across scope hierarchy', () => {
// App that needs access to all logger instances (e.g., for flushing)
class App {
constructor(@inject(select.instances()) public loggers: Logger[]) {}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Logger));
const requestScope = appContainer.createScope({ tags: ['request'] });
// Create loggers in different scopes
appContainer.resolve('ILogger');
requestScope.resolve('ILogger');
const appLevel = appContainer.resolve(App);
const requestLevel = requestScope.resolve(App);
// Request scope sees only its own instance
expect(requestLevel.loggers.length).toBe(1);
// Application scope sees all instances (cascades up from children)
expect(appLevel.loggers.length).toBe(2);
});
it('should return only current scope instances when cascade is disabled', () => {
// Only get instances from current scope, not parent scopes
class App {
constructor(@inject(select.instances().cascade(false)) public loggers: Logger[]) {}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Logger));
const requestScope = appContainer.createScope({ tags: ['request'] });
appContainer.resolve('ILogger');
requestScope.resolve('ILogger');
const appLevel = appContainer.resolve(App);
// Only application-level instance, not request-level
expect(appLevel.loggers.length).toBe(1);
});
it('should filter instances by predicate', () => {
const isLogger = (instance: unknown) => instance instanceof Logger;
class App {
constructor(@inject(select.instances(isLogger)) public loggers: Logger[]) {}
}
const container = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Logger));
const logger0 = container.resolve('ILogger');
const logger1 = container.resolve('ILogger');
const app = container.resolve(App);
expect(app.loggers).toHaveLength(2);
expect(app.loggers[0]).toBe(logger0);
expect(app.loggers[1]).toBe(logger1);
});
});
import { bindTo, Container, inject, register, Registration as R, select } from 'ts-ioc-container';
/**
* User Management Domain - Instance Collection
*
* Sometimes you need access to all instances of a certain type:
* - Collect all active database connections for health checks
* - Gather all loggers to flush buffers before shutdown
* - Find all request handlers for metrics collection
*
* The `select.instances()` token resolves all created instances,
* optionally filtered by a predicate function.
*/
describe('Instances', function () {
@register(bindTo('ILogger'))
class Logger {}
it('should collect instances across scope hierarchy', () => {
// App that needs access to all logger instances (e.g., for flushing)
class App {
constructor(@inject(select.instances()) public loggers: Logger[]) {}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Logger));
const requestScope = appContainer.createScope({ tags: ['request'] });
// Create loggers in different scopes
appContainer.resolve('ILogger');
requestScope.resolve('ILogger');
const appLevel = appContainer.resolve(App);
const requestLevel = requestScope.resolve(App);
// Request scope sees only its own instance
expect(requestLevel.loggers.length).toBe(1);
// Application scope sees all instances (cascades up from children)
expect(appLevel.loggers.length).toBe(2);
});
it('should return only current scope instances when cascade is disabled', () => {
// Only get instances from current scope, not parent scopes
class App {
constructor(@inject(select.instances().cascade(false)) public loggers: Logger[]) {}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Logger));
const requestScope = appContainer.createScope({ tags: ['request'] });
appContainer.resolve('ILogger');
requestScope.resolve('ILogger');
const appLevel = appContainer.resolve(App);
// Only application-level instance, not request-level
expect(appLevel.loggers.length).toBe(1);
});
it('should filter instances by predicate', () => {
const isLogger = (instance: unknown) => instance instanceof Logger;
class App {
constructor(@inject(select.instances(isLogger)) public loggers: Logger[]) {}
}
const container = new Container({ tags: ['application'] }).addRegistration(R.fromClass(Logger));
const logger0 = container.resolve('ILogger');
const logger1 = container.resolve('ILogger');
const app = container.resolve(App);
expect(app.loggers).toHaveLength(2);
expect(app.loggers[0]).toBe(logger0);
expect(app.loggers[1]).toBe(logger1);
});
});
Filtering Instances
import { bindTo, Container, inject, register, Registration as R, select } from 'ts-ioc-container';
/**
* User Management Domain - Filtering Instances by Type
*
* When you need specific types of instances from the container:
* - Get all Logger instances for batch flushing
* - Find all HealthCheckable services for status endpoint
* - Collect all EventHandler instances for event dispatching
*
* The predicate function filters instances at resolution time,
* ensuring you only get the instances you need.
*/
@register(bindTo('ILogger'))
class Logger {}
class Service {}
// Predicate to identify Logger instances
const isLogger = (instance: unknown) => instance instanceof Logger;
class App {
// Only inject instances that pass the isLogger predicate
constructor(@inject(select.instances(isLogger)) public loggers: Logger[]) {}
}
describe('Filtering Instances', function () {
it('should filter instances by type predicate', function () {
const container = new Container({ tags: ['application'] })
.addRegistration(R.fromClass(Logger))
.addRegistration(R.fromClass(Service).bindToKey('IService'));
// Create multiple instances of different types
const logger1 = container.resolve('ILogger');
const logger2 = container.resolve('ILogger');
container.resolve('IService'); // This won't be included
const app = container.resolve(App);
// Only Logger instances are injected, Service is filtered out
expect(app.loggers).toHaveLength(2);
expect(app.loggers[0]).toBe(logger1);
expect(app.loggers[1]).toBe(logger2);
expect(app.loggers.every((l) => l instanceof Logger)).toBe(true);
expect(app.loggers.some((l) => l instanceof Service)).toBe(false);
});
it('should return empty array when no instances match predicate', function () {
const container = new Container({ tags: ['application'] }).addRegistration(
R.fromClass(Service).bindToKey('IService'),
);
container.resolve('IService');
const app = container.resolve(App);
// No Logger instances exist, so empty array is injected
expect(app.loggers).toHaveLength(0);
});
});
import { bindTo, Container, inject, register, Registration as R, select } from 'ts-ioc-container';
/**
* User Management Domain - Filtering Instances by Type
*
* When you need specific types of instances from the container:
* - Get all Logger instances for batch flushing
* - Find all HealthCheckable services for status endpoint
* - Collect all EventHandler instances for event dispatching
*
* The predicate function filters instances at resolution time,
* ensuring you only get the instances you need.
*/
@register(bindTo('ILogger'))
class Logger {}
class Service {}
// Predicate to identify Logger instances
const isLogger = (instance: unknown) => instance instanceof Logger;
class App {
// Only inject instances that pass the isLogger predicate
constructor(@inject(select.instances(isLogger)) public loggers: Logger[]) {}
}
describe('Filtering Instances', function () {
it('should filter instances by type predicate', function () {
const container = new Container({ tags: ['application'] })
.addRegistration(R.fromClass(Logger))
.addRegistration(R.fromClass(Service).bindToKey('IService'));
// Create multiple instances of different types
const logger1 = container.resolve('ILogger');
const logger2 = container.resolve('ILogger');
container.resolve('IService'); // This won't be included
const app = container.resolve(App);
// Only Logger instances are injected, Service is filtered out
expect(app.loggers).toHaveLength(2);
expect(app.loggers[0]).toBe(logger1);
expect(app.loggers[1]).toBe(logger2);
expect(app.loggers.every((l) => l instanceof Logger)).toBe(true);
expect(app.loggers.some((l) => l instanceof Service)).toBe(false);
});
it('should return empty array when no instances match predicate', function () {
const container = new Container({ tags: ['application'] }).addRegistration(
R.fromClass(Service).bindToKey('IService'),
);
container.resolve('IService');
const app = container.resolve(App);
// No Logger instances exist, so empty array is injected
expect(app.loggers).toHaveLength(0);
});
});
Checking Registrations
The hasRegistration() method allows you to check if a registration with a specific key exists in the container. This is useful for conditional registration logic, validation, and debugging.
Key Features
- Checks both the current container’s registrations and parent container registrations
- Works with string keys, symbol keys, and token keys
- Returns
falseafter container disposal - Useful for conditional registration patterns and validation
import { Container, Registration as R, bindTo, register, SingleToken } from 'ts-ioc-container';
/**
* Container Registration Checking - hasRegistration
*
* The `hasRegistration` method allows you to check if a registration with a specific key
* exists in the current container. This is useful for conditional registration logic,
* validation, and debugging.
*
* Key points:
* - Checks only the current container's registrations (not parent containers)
* - Works with string keys, symbol keys, and token keys
* - Returns false after container disposal
* - Useful for conditional registration patterns
*/
describe('hasRegistration', function () {
const createAppContainer = () => new Container({ tags: ['application'] });
it('should return true when registration exists with string key', function () {
const container = createAppContainer();
container.addRegistration(R.fromValue('production').bindToKey('Environment'));
expect(container.hasRegistration('Environment')).toBe(true);
});
it('should return false when registration does not exist', function () {
const container = createAppContainer();
expect(container.hasRegistration('NonExistentService')).toBe(false);
});
it('should work with symbol keys', function () {
const container = createAppContainer();
const serviceKey = Symbol('IService');
container.addRegistration(R.fromValue({ name: 'Service' }).bindToKey(serviceKey));
expect(container.hasRegistration(serviceKey)).toBe(true);
});
it('should work with token keys', function () {
const container = createAppContainer();
const loggerToken = new SingleToken<{ log: (msg: string) => void }>('ILogger');
container.addRegistration(R.fromValue({ log: () => {} }).bindTo(loggerToken));
expect(container.hasRegistration(loggerToken.token)).toBe(true);
});
it('should check current container and parent registrations', function () {
// Parent container has a registration
const parent = createAppContainer();
parent.addRegistration(R.fromValue('parent-config').bindToKey('Config'));
// Child scope does not have the registration
const child = parent.createScope();
child.addRegistration(R.fromValue('child-service').bindToKey('Service'));
// Child should see parent's registration (checks parent as well)
expect(child.hasRegistration('Config')).toBe(true);
// Child should see its own registration
expect(child.hasRegistration('Service')).toBe(true);
// Parent should see its own registration
expect(parent.hasRegistration('Config')).toBe(true);
});
it('should work with class-based registrations', function () {
@register(bindTo('ILogger'))
class Logger {}
const container = createAppContainer();
container.addRegistration(R.fromClass(Logger));
expect(container.hasRegistration('ILogger')).toBe(true);
});
it('should be useful for conditional registration patterns', function () {
const container = createAppContainer();
// Register a base service
container.addRegistration(R.fromValue('base-service').bindToKey('BaseService'));
// Conditionally register an extension only if base exists
if (container.hasRegistration('BaseService')) {
container.addRegistration(R.fromValue('extension-service').bindToKey('ExtensionService'));
}
expect(container.hasRegistration('BaseService')).toBe(true);
expect(container.hasRegistration('ExtensionService')).toBe(true);
});
});
import { Container, Registration as R, bindTo, register, SingleToken } from 'ts-ioc-container';
/**
* Container Registration Checking - hasRegistration
*
* The `hasRegistration` method allows you to check if a registration with a specific key
* exists in the current container. This is useful for conditional registration logic,
* validation, and debugging.
*
* Key points:
* - Checks only the current container's registrations (not parent containers)
* - Works with string keys, symbol keys, and token keys
* - Returns false after container disposal
* - Useful for conditional registration patterns
*/
describe('hasRegistration', function () {
const createAppContainer = () => new Container({ tags: ['application'] });
it('should return true when registration exists with string key', function () {
const container = createAppContainer();
container.addRegistration(R.fromValue('production').bindToKey('Environment'));
expect(container.hasRegistration('Environment')).toBe(true);
});
it('should return false when registration does not exist', function () {
const container = createAppContainer();
expect(container.hasRegistration('NonExistentService')).toBe(false);
});
it('should work with symbol keys', function () {
const container = createAppContainer();
const serviceKey = Symbol('IService');
container.addRegistration(R.fromValue({ name: 'Service' }).bindToKey(serviceKey));
expect(container.hasRegistration(serviceKey)).toBe(true);
});
it('should work with token keys', function () {
const container = createAppContainer();
const loggerToken = new SingleToken<{ log: (msg: string) => void }>('ILogger');
container.addRegistration(R.fromValue({ log: () => {} }).bindTo(loggerToken));
expect(container.hasRegistration(loggerToken.token)).toBe(true);
});
it('should check current container and parent registrations', function () {
// Parent container has a registration
const parent = createAppContainer();
parent.addRegistration(R.fromValue('parent-config').bindToKey('Config'));
// Child scope does not have the registration
const child = parent.createScope();
child.addRegistration(R.fromValue('child-service').bindToKey('Service'));
// Child should see parent's registration (checks parent as well)
expect(child.hasRegistration('Config')).toBe(true);
// Child should see its own registration
expect(child.hasRegistration('Service')).toBe(true);
// Parent should see its own registration
expect(parent.hasRegistration('Config')).toBe(true);
});
it('should work with class-based registrations', function () {
@register(bindTo('ILogger'))
class Logger {}
const container = createAppContainer();
container.addRegistration(R.fromClass(Logger));
expect(container.hasRegistration('ILogger')).toBe(true);
});
it('should be useful for conditional registration patterns', function () {
const container = createAppContainer();
// Register a base service
container.addRegistration(R.fromValue('base-service').bindToKey('BaseService'));
// Conditionally register an extension only if base exists
if (container.hasRegistration('BaseService')) {
container.addRegistration(R.fromValue('extension-service').bindToKey('ExtensionService'));
}
expect(container.hasRegistration('BaseService')).toBe(true);
expect(container.hasRegistration('ExtensionService')).toBe(true);
});
});
Use Cases
- Conditional registration - Register extensions only if base services exist
- Validation - Verify required dependencies are registered before use
- Debugging - Check if a registration exists during development
- Dynamic configuration - Build container configuration based on existing registrations
Disposal
Proper resource cleanup is essential for preventing memory leaks. The container provides a disposal mechanism that cleans up all resources and prevents further usage.
import 'reflect-metadata';
import { Container, ContainerDisposedError, Registration as R, select } from 'ts-ioc-container';
/**
* User Management Domain - Resource Cleanup
*
* When a scope ends (e.g., HTTP request completes), resources must be cleaned up:
* - Database connections returned to pool
* - File handles closed
* - Temporary files deleted
* - Cache entries cleared
*
* The container.dispose() method:
* 1. Executes all onDispose hooks
* 2. Clears all instances and registrations
* 3. Detaches from parent scope
* 4. Prevents further resolution
*/
// Simulates a database connection that must be closed
class DatabaseConnection {
isClosed = false;
query(sql: string): string[] {
if (this.isClosed) {
throw new Error('Connection is closed');
}
return [`Result for: ${sql}`];
}
close(): void {
this.isClosed = true;
}
}
describe('Disposing', function () {
it('should dispose container and prevent further usage', function () {
const appContainer = new Container({ tags: ['application'] }).addRegistration(
R.fromClass(DatabaseConnection).bindTo('IDatabase'),
);
// Create a request scope with a database connection
const requestScope = appContainer.createScope({ tags: ['request'] });
const connection = requestScope.resolve<DatabaseConnection>('IDatabase');
// Connection works normally
expect(connection.query('SELECT * FROM users')).toEqual(['Result for: SELECT * FROM users']);
// Request ends - dispose the scope
requestScope.dispose();
// Scope is now unusable
expect(() => requestScope.resolve('IDatabase')).toThrow(ContainerDisposedError);
// All instances are cleared
expect(select.instances().resolve(requestScope).length).toBe(0);
// Application container is still functional
expect(appContainer.resolve<DatabaseConnection>('IDatabase')).toBeDefined();
});
it('should clean up request-scoped resources on request end', function () {
const appContainer = new Container({ tags: ['application'] }).addRegistration(
R.fromClass(DatabaseConnection).bindTo('IDatabase'),
);
// Simulate Express.js request lifecycle
function handleRequest(): { connection: DatabaseConnection; scope: Container } {
const requestScope = appContainer.createScope({ tags: ['request'] }) as Container;
const connection = requestScope.resolve<DatabaseConnection>('IDatabase');
// Do some work...
connection.query('INSERT INTO sessions VALUES (...)');
return { connection, scope: requestScope };
}
// Request 1
const request1 = handleRequest();
expect(request1.connection.isClosed).toBe(false);
// Request 1 ends - in Express, this would be in res.on('finish')
request1.connection.close();
request1.scope.dispose();
// Request 2 gets a fresh connection
const request2 = handleRequest();
expect(request2.connection.isClosed).toBe(false);
expect(request2.connection).not.toBe(request1.connection);
// Cleanup
request2.connection.close();
request2.scope.dispose();
});
});
import 'reflect-metadata';
import { Container, ContainerDisposedError, Registration as R, select } from 'ts-ioc-container';
/**
* User Management Domain - Resource Cleanup
*
* When a scope ends (e.g., HTTP request completes), resources must be cleaned up:
* - Database connections returned to pool
* - File handles closed
* - Temporary files deleted
* - Cache entries cleared
*
* The container.dispose() method:
* 1. Executes all onDispose hooks
* 2. Clears all instances and registrations
* 3. Detaches from parent scope
* 4. Prevents further resolution
*/
// Simulates a database connection that must be closed
class DatabaseConnection {
isClosed = false;
query(sql: string): string[] {
if (this.isClosed) {
throw new Error('Connection is closed');
}
return [`Result for: ${sql}`];
}
close(): void {
this.isClosed = true;
}
}
describe('Disposing', function () {
it('should dispose container and prevent further usage', function () {
const appContainer = new Container({ tags: ['application'] }).addRegistration(
R.fromClass(DatabaseConnection).bindTo('IDatabase'),
);
// Create a request scope with a database connection
const requestScope = appContainer.createScope({ tags: ['request'] });
const connection = requestScope.resolve<DatabaseConnection>('IDatabase');
// Connection works normally
expect(connection.query('SELECT * FROM users')).toEqual(['Result for: SELECT * FROM users']);
// Request ends - dispose the scope
requestScope.dispose();
// Scope is now unusable
expect(() => requestScope.resolve('IDatabase')).toThrow(ContainerDisposedError);
// All instances are cleared
expect(select.instances().resolve(requestScope).length).toBe(0);
// Application container is still functional
expect(appContainer.resolve<DatabaseConnection>('IDatabase')).toBeDefined();
});
it('should clean up request-scoped resources on request end', function () {
const appContainer = new Container({ tags: ['application'] }).addRegistration(
R.fromClass(DatabaseConnection).bindTo('IDatabase'),
);
// Simulate Express.js request lifecycle
function handleRequest(): { connection: DatabaseConnection; scope: Container } {
const requestScope = appContainer.createScope({ tags: ['request'] }) as Container;
const connection = requestScope.resolve<DatabaseConnection>('IDatabase');
// Do some work...
connection.query('INSERT INTO sessions VALUES (...)');
return { connection, scope: requestScope };
}
// Request 1
const request1 = handleRequest();
expect(request1.connection.isClosed).toBe(false);
// Request 1 ends - in Express, this would be in res.on('finish')
request1.connection.close();
request1.scope.dispose();
// Request 2 gets a fresh connection
const request2 = handleRequest();
expect(request2.connection.isClosed).toBe(false);
expect(request2.connection).not.toBe(request1.connection);
// Cleanup
request2.connection.close();
request2.scope.dispose();
});
});
Disposal Behavior
- Disposes the container only. Not children
- Unregisters all providers
- Clears all cached instances
- Throws
ContainerDisposedErroron any subsequent operations
Lazy Loading
Lazy loading defers the instantiation of dependencies until they’re actually accessed. This can improve startup performance and enable circular dependency resolution.
import 'reflect-metadata';
import { Container, inject, register, Registration as R, select as s, singleton } from 'ts-ioc-container';
/**
* User Management Domain - Lazy Loading
*
* Some services are expensive to initialize:
* - EmailNotifier: Establishes SMTP connection
* - ReportGenerator: Loads templates, initializes PDF engine
* - ExternalApiClient: Authenticates with third-party service
*
* Lazy loading defers instantiation until first use.
* This improves startup time and avoids initializing unused services.
*
* Use cases:
* - Services used only in specific code paths (error notification)
* - Optional features that may not be triggered
* - Breaking circular dependencies
*/
describe('lazy provider', () => {
// Tracks whether SMTP connection was established
@register(singleton())
class SmtpConnectionStatus {
isConnected = false;
connect() {
this.isConnected = true;
}
}
// EmailNotifier is expensive - establishes SMTP connection on construction
class EmailNotifier {
constructor(@inject('SmtpConnectionStatus') private smtp: SmtpConnectionStatus) {
// Simulate expensive SMTP connection
this.smtp.connect();
}
sendPasswordReset(email: string): string {
return `Password reset sent to ${email}`;
}
}
// AuthService might need to send password reset emails
// But most login requests don't need email (only password reset does)
class AuthService {
constructor(@inject(s.token('EmailNotifier').lazy()) public emailNotifier: EmailNotifier) {}
login(email: string, password: string): boolean {
// Most requests just validate credentials - no email needed
return email === 'admin@example.com' && password === 'secret';
}
requestPasswordReset(email: string): string {
// Only here do we actually need the EmailNotifier
return this.emailNotifier.sendPasswordReset(email);
}
}
function createContainer() {
const container = new Container();
container.addRegistration(R.fromClass(SmtpConnectionStatus)).addRegistration(R.fromClass(EmailNotifier));
return container;
}
it('should not connect to SMTP until email is actually needed', () => {
const container = createContainer();
// AuthService is created, but EmailNotifier is NOT instantiated yet
container.resolve(AuthService);
const smtp = container.resolve<SmtpConnectionStatus>('SmtpConnectionStatus');
// SMTP connection was NOT established - lazy loading deferred it
expect(smtp.isConnected).toBe(false);
});
it('should connect to SMTP only when sending email', () => {
const container = createContainer();
const authService = container.resolve(AuthService);
const smtp = container.resolve<SmtpConnectionStatus>('SmtpConnectionStatus');
// Trigger password reset - this actually uses EmailNotifier
const result = authService.requestPasswordReset('user@example.com');
// Now SMTP connection was established
expect(result).toBe('Password reset sent to user@example.com');
expect(smtp.isConnected).toBe(true);
});
it('should only create one instance even with multiple method calls', () => {
const container = createContainer();
const authService = container.resolve(AuthService);
// Multiple password resets
authService.requestPasswordReset('user1@example.com');
authService.requestPasswordReset('user2@example.com');
// Only one EmailNotifier instance was created
const emailNotifiers = Array.from(container.getInstances()).filter((x) => x instanceof EmailNotifier);
expect(emailNotifiers.length).toBe(1);
});
it('should trigger instantiation when accessing property on lazy object', () => {
const container = createContainer();
const authService = container.resolve(AuthService);
const smtp = container.resolve<SmtpConnectionStatus>('SmtpConnectionStatus');
// Just getting the proxy doesn't trigger instantiation
const notifier = authService.emailNotifier;
expect(notifier).toBeDefined();
expect(smtp.isConnected).toBe(false); // Still lazy!
// Accessing a property ON the lazy object triggers instantiation
const method = notifier.sendPasswordReset;
expect(method).toBeDefined();
expect(smtp.isConnected).toBe(true); // Now instantiated!
});
});
import 'reflect-metadata';
import { Container, inject, register, Registration as R, select as s, singleton } from 'ts-ioc-container';
/**
* User Management Domain - Lazy Loading
*
* Some services are expensive to initialize:
* - EmailNotifier: Establishes SMTP connection
* - ReportGenerator: Loads templates, initializes PDF engine
* - ExternalApiClient: Authenticates with third-party service
*
* Lazy loading defers instantiation until first use.
* This improves startup time and avoids initializing unused services.
*
* Use cases:
* - Services used only in specific code paths (error notification)
* - Optional features that may not be triggered
* - Breaking circular dependencies
*/
describe('lazy provider', () => {
// Tracks whether SMTP connection was established
@register(singleton())
class SmtpConnectionStatus {
isConnected = false;
connect() {
this.isConnected = true;
}
}
// EmailNotifier is expensive - establishes SMTP connection on construction
class EmailNotifier {
constructor(@inject('SmtpConnectionStatus') private smtp: SmtpConnectionStatus) {
// Simulate expensive SMTP connection
this.smtp.connect();
}
sendPasswordReset(email: string): string {
return `Password reset sent to ${email}`;
}
}
// AuthService might need to send password reset emails
// But most login requests don't need email (only password reset does)
class AuthService {
constructor(@inject(s.token('EmailNotifier').lazy()) public emailNotifier: EmailNotifier) {}
login(email: string, password: string): boolean {
// Most requests just validate credentials - no email needed
return email === 'admin@example.com' && password === 'secret';
}
requestPasswordReset(email: string): string {
// Only here do we actually need the EmailNotifier
return this.emailNotifier.sendPasswordReset(email);
}
}
function createContainer() {
const container = new Container();
container.addRegistration(R.fromClass(SmtpConnectionStatus)).addRegistration(R.fromClass(EmailNotifier));
return container;
}
it('should not connect to SMTP until email is actually needed', () => {
const container = createContainer();
// AuthService is created, but EmailNotifier is NOT instantiated yet
container.resolve(AuthService);
const smtp = container.resolve<SmtpConnectionStatus>('SmtpConnectionStatus');
// SMTP connection was NOT established - lazy loading deferred it
expect(smtp.isConnected).toBe(false);
});
it('should connect to SMTP only when sending email', () => {
const container = createContainer();
const authService = container.resolve(AuthService);
const smtp = container.resolve<SmtpConnectionStatus>('SmtpConnectionStatus');
// Trigger password reset - this actually uses EmailNotifier
const result = authService.requestPasswordReset('user@example.com');
// Now SMTP connection was established
expect(result).toBe('Password reset sent to user@example.com');
expect(smtp.isConnected).toBe(true);
});
it('should only create one instance even with multiple method calls', () => {
const container = createContainer();
const authService = container.resolve(AuthService);
// Multiple password resets
authService.requestPasswordReset('user1@example.com');
authService.requestPasswordReset('user2@example.com');
// Only one EmailNotifier instance was created
const emailNotifiers = Array.from(container.getInstances()).filter((x) => x instanceof EmailNotifier);
expect(emailNotifiers.length).toBe(1);
});
it('should trigger instantiation when accessing property on lazy object', () => {
const container = createContainer();
const authService = container.resolve(AuthService);
const smtp = container.resolve<SmtpConnectionStatus>('SmtpConnectionStatus');
// Just getting the proxy doesn't trigger instantiation
const notifier = authService.emailNotifier;
expect(notifier).toBeDefined();
expect(smtp.isConnected).toBe(false); // Still lazy!
// Accessing a property ON the lazy object triggers instantiation
const method = notifier.sendPasswordReset;
expect(method).toBeDefined();
expect(smtp.isConnected).toBe(true); // Now instantiated!
});
});
Memory Management
The container provides explicit disposal to prevent memory leaks:
dispose()clears all providers and instances- Child scopes are automatically disposed when parent is disposed
- Instance tracking allows manual cleanup if needed
- Hooks can be used to release external resources during disposal
Container Modules
Container modules encapsulate registration logic, making it easy to organize and compose your dependency configuration. This is particularly useful for environment-specific setups or feature-based organization.
Creating a Module
Modules implement the IContainerModule interface and use the applyTo method to register dependencies:
import 'reflect-metadata';
import { bindTo, Container, type IContainer, type IContainerModule, register, Registration as R } from 'ts-ioc-container';
/**
* User Management Domain - Container Modules
*
* Modules organize related registrations and allow swapping implementations
* based on environment (development, testing, production).
*
* Common module patterns:
* - ProductionModule: Real database, external APIs, email service
* - DevelopmentModule: In-memory database, mock APIs, console logging
* - TestingModule: Mocks with assertion capabilities
*
* This enables:
* - Easy environment switching
* - Isolated testing without external dependencies
* - Feature flags via module composition
*/
// Auth service interface - same API for all environments
interface IAuthService {
authenticate(email: string, password: string): boolean;
getServiceType(): string;
}
// Production: Real authentication with database lookup
@register(bindTo('IAuthService'))
class ProductionAuthService implements IAuthService {
authenticate(email: string, password: string): boolean {
// In production, this would query the database
return email === 'admin@example.com' && password === 'secure_password';
}
getServiceType(): string {
return 'production';
}
}
// Development: Accepts any credentials for easy testing
@register(bindTo('IAuthService'))
class DevelopmentAuthService implements IAuthService {
authenticate(_email: string, _password: string): boolean {
// Always succeed in development for easier testing
return true;
}
getServiceType(): string {
return 'development';
}
}
// Production module - real services with security
class ProductionModule implements IContainerModule {
applyTo(container: IContainer): void {
container.addRegistration(R.fromClass(ProductionAuthService));
// In a real app, also register:
// - Real database connection
// - External email service
// - Payment gateway
}
}
// Development module - mocks and conveniences
class DevelopmentModule implements IContainerModule {
applyTo(container: IContainer): void {
container.addRegistration(R.fromClass(DevelopmentAuthService));
// In a real app, also register:
// - In-memory database
// - Console email logger
// - Mock payment gateway
}
}
describe('Container Modules', function () {
function createContainer(isProduction: boolean) {
const module = isProduction ? new ProductionModule() : new DevelopmentModule();
return new Container().useModule(module);
}
it('should use production auth with strict validation', function () {
const container = createContainer(true);
const auth = container.resolve<IAuthService>('IAuthService');
expect(auth.getServiceType()).toBe('production');
expect(auth.authenticate('admin@example.com', 'secure_password')).toBe(true);
expect(auth.authenticate('admin@example.com', 'wrong_password')).toBe(false);
});
it('should use development auth with permissive validation', function () {
const container = createContainer(false);
const auth = container.resolve<IAuthService>('IAuthService');
expect(auth.getServiceType()).toBe('development');
// Development mode accepts any credentials
expect(auth.authenticate('any@email.com', 'any_password')).toBe(true);
});
it('should allow composing multiple modules', function () {
// Modules can be composed for feature flags or A/B testing
class FeatureFlagModule implements IContainerModule {
constructor(private enableNewFeature: boolean) {}
applyTo(container: IContainer): void {
if (this.enableNewFeature) {
// Register new feature implementations
}
}
}
const container = new Container().useModule(new ProductionModule()).useModule(new FeatureFlagModule(true));
// Base services from ProductionModule
expect(container.resolve<IAuthService>('IAuthService').getServiceType()).toBe('production');
});
});
import 'reflect-metadata';
import { bindTo, Container, type IContainer, type IContainerModule, register, Registration as R } from 'ts-ioc-container';
/**
* User Management Domain - Container Modules
*
* Modules organize related registrations and allow swapping implementations
* based on environment (development, testing, production).
*
* Common module patterns:
* - ProductionModule: Real database, external APIs, email service
* - DevelopmentModule: In-memory database, mock APIs, console logging
* - TestingModule: Mocks with assertion capabilities
*
* This enables:
* - Easy environment switching
* - Isolated testing without external dependencies
* - Feature flags via module composition
*/
// Auth service interface - same API for all environments
interface IAuthService {
authenticate(email: string, password: string): boolean;
getServiceType(): string;
}
// Production: Real authentication with database lookup
@register(bindTo('IAuthService'))
class ProductionAuthService implements IAuthService {
authenticate(email: string, password: string): boolean {
// In production, this would query the database
return email === 'admin@example.com' && password === 'secure_password';
}
getServiceType(): string {
return 'production';
}
}
// Development: Accepts any credentials for easy testing
@register(bindTo('IAuthService'))
class DevelopmentAuthService implements IAuthService {
authenticate(_email: string, _password: string): boolean {
// Always succeed in development for easier testing
return true;
}
getServiceType(): string {
return 'development';
}
}
// Production module - real services with security
class ProductionModule implements IContainerModule {
applyTo(container: IContainer): void {
container.addRegistration(R.fromClass(ProductionAuthService));
// In a real app, also register:
// - Real database connection
// - External email service
// - Payment gateway
}
}
// Development module - mocks and conveniences
class DevelopmentModule implements IContainerModule {
applyTo(container: IContainer): void {
container.addRegistration(R.fromClass(DevelopmentAuthService));
// In a real app, also register:
// - In-memory database
// - Console email logger
// - Mock payment gateway
}
}
describe('Container Modules', function () {
function createContainer(isProduction: boolean) {
const module = isProduction ? new ProductionModule() : new DevelopmentModule();
return new Container().useModule(module);
}
it('should use production auth with strict validation', function () {
const container = createContainer(true);
const auth = container.resolve<IAuthService>('IAuthService');
expect(auth.getServiceType()).toBe('production');
expect(auth.authenticate('admin@example.com', 'secure_password')).toBe(true);
expect(auth.authenticate('admin@example.com', 'wrong_password')).toBe(false);
});
it('should use development auth with permissive validation', function () {
const container = createContainer(false);
const auth = container.resolve<IAuthService>('IAuthService');
expect(auth.getServiceType()).toBe('development');
// Development mode accepts any credentials
expect(auth.authenticate('any@email.com', 'any_password')).toBe(true);
});
it('should allow composing multiple modules', function () {
// Modules can be composed for feature flags or A/B testing
class FeatureFlagModule implements IContainerModule {
constructor(private enableNewFeature: boolean) {}
applyTo(container: IContainer): void {
if (this.enableNewFeature) {
// Register new feature implementations
}
}
}
const container = new Container().useModule(new ProductionModule()).useModule(new FeatureFlagModule(true));
// Base services from ProductionModule
expect(container.resolve<IAuthService>('IAuthService').getServiceType()).toBe('production');
});
});
Use Cases
- Environment configuration - Different implementations for dev/staging/production
- Feature organization - Group related registrations together
- Testing - Easy to swap modules for test scenarios
- Modular architecture - Compose containers from smaller, reusable modules
Key Design Decisions
1. Separation of Concerns
The architecture separates three distinct responsibilities:
- Container: Manages lifecycle and coordinates resolution
- Provider: Handles instance creation
- Injector: Handles dependency injection
This separation makes each component testable and replaceable independently.
2. Scope-Based Isolation
Scopes provide isolation while maintaining a parent-child relationship. This design enables:
- Request-level isolation in web applications
- Feature-level separation
- Hierarchical dependency resolution
- Per-scope singleton instances
3. Tag-Based Scoping
Tags provide a flexible way to control provider availability without hardcoding scope relationships. This:
- Enables environment-specific configurations
- Supports feature flags
- Allows dynamic scope matching
- Makes testing easier
4. Composite Pattern
The scope hierarchy uses the Composite pattern, where containers can contain other containers. This allows uniform treatment of individual containers and container hierarchies.
5. Null Object Pattern
The EmptyContainer class implements the Null Object pattern, providing a safe default parent for root containers. This eliminates null checks throughout the codebase.
Best Practices
- Always dispose request scopes - In Express.js, use
res.on('finish', () => req.container.dispose())to clean up - Use tags for scope types - Common tags:
application,request,transaction,admin - Restrict sensitive services - Use
scopeAccess()to limit access to admin-only or application-only services - Keep singletons stateless - Application-scoped singletons shouldn’t hold request-specific state
- Use lazy loading for expensive services - Defer SMTP connections, external API clients until actually needed
- Separate dev/prod with modules - Use
IContainerModuleto swap implementations by environment
API Reference
Container Methods
resolve<T>(key: DependencyKey | constructor<T>, options?: ResolveOptions): T- Resolve a dependencycreateScope(options?: ScopeOptions): IContainer- Create a child scopeaddRegistration(registration: IRegistration): IContainer- Register a providerhasRegistration(key: DependencyKey): boolean- Check if a registration exists in the current container or parent containersgetRegistrations(): IRegistration[]- Get all registrations from current container and parent containersdispose(): void- Dispose the container and all resourcesgetInstances(): Iterable<Instance>- Get all instances created by this containerhasTag(tag: Tag): boolean- Check if container has a specific tagaddTags(...tags: Tag[]): void- Add one or more tags to the container dynamically