Provider
Goal: Create a dependency (instance).
Providers are factories that create dependency instances. They can be configured with various features like singletons, argument binding, visibility control, aliases, and decorators. Understanding providers is key to mastering the IoC container.
Factory Pattern
Providers implement the Factory pattern, encapsulating the creation logic of dependencies. This allows for complex instantiation logic while keeping the container simple.
Provider Types
There are three main ways to create providers:
- Class Provider:
Provider.fromClass(Logger)- Creates instances from a class - Value Provider:
Provider.fromValue(value)- Returns a constant value - Factory Provider:
new Provider((container, ...args) => ...)- Custom factory function
Singleton
Singleton providers ensure only one instance is created per scope. This is perfect for services like loggers, database connections, or configuration managers.
import 'reflect-metadata';
import { bindTo, Container, register, Registration as R, singleton } from 'ts-ioc-container';
/**
* User Management Domain - Singleton Pattern
*
* Singletons are services that should only have one instance per scope.
* Common examples:
* - PasswordHasher: Expensive to initialize (loads crypto config)
* - DatabasePool: Connection pool shared across requests
* - ConfigService: Application configuration loaded once
*
* Note: "singleton" in ts-ioc-container means "one instance per scope",
* not "one instance globally". Each scope gets its own singleton instance.
*/
// PasswordHasher is expensive to create - should be singleton
@register(bindTo('IPasswordHasher'), singleton())
class PasswordHasher {
private readonly salt: string;
constructor() {
// Simulate expensive initialization (loading crypto config, etc.)
this.salt = 'random_salt_' + Math.random().toString(36);
}
hash(password: string): string {
return `hashed_${password}_${this.salt}`;
}
verify(password: string, hash: string): boolean {
return this.hash(password) === hash;
}
}
describe('Singleton', function () {
function createAppContainer() {
return new Container({ tags: ['application'] });
}
it('should resolve the same PasswordHasher for every request in same scope', function () {
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
// Multiple resolves return the same instance
const hasher1 = appContainer.resolve<PasswordHasher>('IPasswordHasher');
const hasher2 = appContainer.resolve<PasswordHasher>('IPasswordHasher');
expect(hasher1).toBe(hasher2);
expect(hasher1.hash('password')).toBe(hasher2.hash('password'));
});
it('should create different singleton per request scope', function () {
// Application-level singleton
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
// Each request scope gets its own singleton instance
// This is useful when you want per-request caching
const request1 = appContainer.createScope({ tags: ['request'] });
const request2 = appContainer.createScope({ tags: ['request'] });
const appHasher = appContainer.resolve<PasswordHasher>('IPasswordHasher');
const request1Hasher = request1.resolve<PasswordHasher>('IPasswordHasher');
const request2Hasher = request2.resolve<PasswordHasher>('IPasswordHasher');
// Each scope has its own instance
expect(appHasher).not.toBe(request1Hasher);
expect(request1Hasher).not.toBe(request2Hasher);
});
it('should maintain singleton within a scope', function () {
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
const requestScope = appContainer.createScope({ tags: ['request'] });
// Within the same scope, singleton is maintained
const hasher1 = requestScope.resolve<PasswordHasher>('IPasswordHasher');
const hasher2 = requestScope.resolve<PasswordHasher>('IPasswordHasher');
expect(hasher1).toBe(hasher2);
});
});
import 'reflect-metadata';
import { bindTo, Container, register, Registration as R, singleton } from 'ts-ioc-container';
/**
* User Management Domain - Singleton Pattern
*
* Singletons are services that should only have one instance per scope.
* Common examples:
* - PasswordHasher: Expensive to initialize (loads crypto config)
* - DatabasePool: Connection pool shared across requests
* - ConfigService: Application configuration loaded once
*
* Note: "singleton" in ts-ioc-container means "one instance per scope",
* not "one instance globally". Each scope gets its own singleton instance.
*/
// PasswordHasher is expensive to create - should be singleton
@register(bindTo('IPasswordHasher'), singleton())
class PasswordHasher {
private readonly salt: string;
constructor() {
// Simulate expensive initialization (loading crypto config, etc.)
this.salt = 'random_salt_' + Math.random().toString(36);
}
hash(password: string): string {
return `hashed_${password}_${this.salt}`;
}
verify(password: string, hash: string): boolean {
return this.hash(password) === hash;
}
}
describe('Singleton', function () {
function createAppContainer() {
return new Container({ tags: ['application'] });
}
it('should resolve the same PasswordHasher for every request in same scope', function () {
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
// Multiple resolves return the same instance
const hasher1 = appContainer.resolve<PasswordHasher>('IPasswordHasher');
const hasher2 = appContainer.resolve<PasswordHasher>('IPasswordHasher');
expect(hasher1).toBe(hasher2);
expect(hasher1.hash('password')).toBe(hasher2.hash('password'));
});
it('should create different singleton per request scope', function () {
// Application-level singleton
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
// Each request scope gets its own singleton instance
// This is useful when you want per-request caching
const request1 = appContainer.createScope({ tags: ['request'] });
const request2 = appContainer.createScope({ tags: ['request'] });
const appHasher = appContainer.resolve<PasswordHasher>('IPasswordHasher');
const request1Hasher = request1.resolve<PasswordHasher>('IPasswordHasher');
const request2Hasher = request2.resolve<PasswordHasher>('IPasswordHasher');
// Each scope has its own instance
expect(appHasher).not.toBe(request1Hasher);
expect(request1Hasher).not.toBe(request2Hasher);
});
it('should maintain singleton within a scope', function () {
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
const requestScope = appContainer.createScope({ tags: ['request'] });
// Within the same scope, singleton is maintained
const hasher1 = requestScope.resolve<PasswordHasher>('IPasswordHasher');
const hasher2 = requestScope.resolve<PasswordHasher>('IPasswordHasher');
expect(hasher1).toBe(hasher2);
});
});
Per-Scope Singletons
Each scope maintains its own singleton instance. This means different scopes will have different instances:
const container = new Container()
.addRegistration(R.fromClass(Logger));
const child = container.createScope();
// Different instances in different scopes
expect(container.resolve('logger')).not.toBe(child.resolve('logger'));
// Same instance within the same scope
expect(child.resolve('logger')).toBe(child.resolve('logger'));const container = new Container()
.addRegistration(R.fromClass(Logger));
const child = container.createScope();
// Different instances in different scopes
expect(container.resolve('logger')).not.toBe(child.resolve('logger'));
// Same instance within the same scope
expect(child.resolve('logger')).toBe(child.resolve('logger'));Use Cases
- PasswordHasher - Expensive crypto initialization, shared per app
- DatabasePool - Connection pool shared across requests
- ConfigService - Application configuration loaded once
- SessionManager - Singleton per request scope for user session
Arguments
Argument providers allow you to bind constructor arguments at registration time. This is useful for configuration values, dependencies that should be resolved at provider creation, or default values.
import 'reflect-metadata';
import { bindTo, Container, register, Registration as R, singleton } from 'ts-ioc-container';
/**
* User Management Domain - Singleton Pattern
*
* Singletons are services that should only have one instance per scope.
* Common examples:
* - PasswordHasher: Expensive to initialize (loads crypto config)
* - DatabasePool: Connection pool shared across requests
* - ConfigService: Application configuration loaded once
*
* Note: "singleton" in ts-ioc-container means "one instance per scope",
* not "one instance globally". Each scope gets its own singleton instance.
*/
// PasswordHasher is expensive to create - should be singleton
@register(bindTo('IPasswordHasher'), singleton())
class PasswordHasher {
private readonly salt: string;
constructor() {
// Simulate expensive initialization (loading crypto config, etc.)
this.salt = 'random_salt_' + Math.random().toString(36);
}
hash(password: string): string {
return `hashed_${password}_${this.salt}`;
}
verify(password: string, hash: string): boolean {
return this.hash(password) === hash;
}
}
describe('Singleton', function () {
function createAppContainer() {
return new Container({ tags: ['application'] });
}
it('should resolve the same PasswordHasher for every request in same scope', function () {
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
// Multiple resolves return the same instance
const hasher1 = appContainer.resolve<PasswordHasher>('IPasswordHasher');
const hasher2 = appContainer.resolve<PasswordHasher>('IPasswordHasher');
expect(hasher1).toBe(hasher2);
expect(hasher1.hash('password')).toBe(hasher2.hash('password'));
});
it('should create different singleton per request scope', function () {
// Application-level singleton
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
// Each request scope gets its own singleton instance
// This is useful when you want per-request caching
const request1 = appContainer.createScope({ tags: ['request'] });
const request2 = appContainer.createScope({ tags: ['request'] });
const appHasher = appContainer.resolve<PasswordHasher>('IPasswordHasher');
const request1Hasher = request1.resolve<PasswordHasher>('IPasswordHasher');
const request2Hasher = request2.resolve<PasswordHasher>('IPasswordHasher');
// Each scope has its own instance
expect(appHasher).not.toBe(request1Hasher);
expect(request1Hasher).not.toBe(request2Hasher);
});
it('should maintain singleton within a scope', function () {
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
const requestScope = appContainer.createScope({ tags: ['request'] });
// Within the same scope, singleton is maintained
const hasher1 = requestScope.resolve<PasswordHasher>('IPasswordHasher');
const hasher2 = requestScope.resolve<PasswordHasher>('IPasswordHasher');
expect(hasher1).toBe(hasher2);
});
});
import 'reflect-metadata';
import { bindTo, Container, register, Registration as R, singleton } from 'ts-ioc-container';
/**
* User Management Domain - Singleton Pattern
*
* Singletons are services that should only have one instance per scope.
* Common examples:
* - PasswordHasher: Expensive to initialize (loads crypto config)
* - DatabasePool: Connection pool shared across requests
* - ConfigService: Application configuration loaded once
*
* Note: "singleton" in ts-ioc-container means "one instance per scope",
* not "one instance globally". Each scope gets its own singleton instance.
*/
// PasswordHasher is expensive to create - should be singleton
@register(bindTo('IPasswordHasher'), singleton())
class PasswordHasher {
private readonly salt: string;
constructor() {
// Simulate expensive initialization (loading crypto config, etc.)
this.salt = 'random_salt_' + Math.random().toString(36);
}
hash(password: string): string {
return `hashed_${password}_${this.salt}`;
}
verify(password: string, hash: string): boolean {
return this.hash(password) === hash;
}
}
describe('Singleton', function () {
function createAppContainer() {
return new Container({ tags: ['application'] });
}
it('should resolve the same PasswordHasher for every request in same scope', function () {
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
// Multiple resolves return the same instance
const hasher1 = appContainer.resolve<PasswordHasher>('IPasswordHasher');
const hasher2 = appContainer.resolve<PasswordHasher>('IPasswordHasher');
expect(hasher1).toBe(hasher2);
expect(hasher1.hash('password')).toBe(hasher2.hash('password'));
});
it('should create different singleton per request scope', function () {
// Application-level singleton
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
// Each request scope gets its own singleton instance
// This is useful when you want per-request caching
const request1 = appContainer.createScope({ tags: ['request'] });
const request2 = appContainer.createScope({ tags: ['request'] });
const appHasher = appContainer.resolve<PasswordHasher>('IPasswordHasher');
const request1Hasher = request1.resolve<PasswordHasher>('IPasswordHasher');
const request2Hasher = request2.resolve<PasswordHasher>('IPasswordHasher');
// Each scope has its own instance
expect(appHasher).not.toBe(request1Hasher);
expect(request1Hasher).not.toBe(request2Hasher);
});
it('should maintain singleton within a scope', function () {
const appContainer = createAppContainer().addRegistration(R.fromClass(PasswordHasher));
const requestScope = appContainer.createScope({ tags: ['request'] });
// Within the same scope, singleton is maintained
const hasher1 = requestScope.resolve<PasswordHasher>('IPasswordHasher');
const hasher2 = requestScope.resolve<PasswordHasher>('IPasswordHasher');
expect(hasher1).toBe(hasher2);
});
});
Argument Priority
Provider arguments take precedence over arguments passed to resolve():
const container = new Container()
.addRegistration(R.fromClass(Logger).pipe(args('name')));
// Provider argument 'name' takes priority
const logger = container.resolve<Logger>('logger', { args: ['file'] });
expect(logger.name).toBe('name');
expect(logger.type).toBe('file'); // Second argument still passedconst container = new Container()
.addRegistration(R.fromClass(Logger).pipe(args('name')));
// Provider argument 'name' takes priority
const logger = container.resolve<Logger>('logger', { args: ['file'] });
expect(logger.name).toBe('name');
expect(logger.type).toBe('file'); // Second argument still passedVisibility
Visibility control allows you to restrict which scopes can access certain dependencies using ScopeAccessRule. This is useful for implementing access control, feature flags, or environment-specific services.
import 'reflect-metadata';
import {
bindTo,
Container,
DependencyNotFoundError,
register,
Registration as R,
scope,
scopeAccess,
singleton,
} from 'ts-ioc-container';
/**
* User Management Domain - Visibility Control
*
* Some services should only be accessible in specific scopes:
* - AdminService: Only accessible in admin routes
* - AuditLogger: Only accessible at application level (not per-request)
* - DebugService: Only accessible in development environment
*
* scopeAccess() controls VISIBILITY - whether a registered service
* can be resolved from a particular scope.
*
* This provides security-by-design:
* - Prevents accidental access to sensitive services
* - Enforces architectural boundaries
* - Catches misuse at resolution time (not runtime)
*/
describe('Visibility', function () {
it('should restrict admin services to admin routes only', () => {
// UserManagementService can delete users - admin only!
@register(
bindTo('IUserManagement'),
scope((s) => s.hasTag('application')), // Registered at app level
singleton(),
// Only accessible from admin scope, not regular request scope
scopeAccess(({ invocationScope }) => invocationScope.hasTag('admin')),
)
class UserManagementService {
deleteUser(userId: string): string {
return `Deleted user ${userId}`;
}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(UserManagementService));
// Admin route scope
const adminScope = appContainer.createScope({ tags: ['request', 'admin'] });
// Regular user route scope
const userScope = appContainer.createScope({ tags: ['request', 'user'] });
// Admin can access UserManagementService
const adminService = adminScope.resolve<UserManagementService>('IUserManagement');
expect(adminService.deleteUser('user-123')).toBe('Deleted user user-123');
// Regular users cannot access it - security enforced at DI level
expect(() => userScope.resolve('IUserManagement')).toThrowError(DependencyNotFoundError);
});
it('should restrict application-level services from request scope', () => {
// AuditLogger should only be used at application initialization
// Not from request handlers (to prevent log corruption from concurrent access)
@register(
bindTo('IAuditLogger'),
scope((s) => s.hasTag('application')),
singleton(),
// Only accessible from the scope where it was registered
scopeAccess(({ invocationScope, providerScope }) => invocationScope === providerScope),
)
class AuditLogger {
log(message: string): string {
return `AUDIT: ${message}`;
}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(AuditLogger));
const requestScope = appContainer.createScope({ tags: ['request'] });
// Application can use AuditLogger (for startup logging)
expect(appContainer.resolve<AuditLogger>('IAuditLogger').log('App started')).toBe('AUDIT: App started');
// Request handlers cannot access it directly
expect(() => requestScope.resolve('IAuditLogger')).toThrowError(DependencyNotFoundError);
});
});
import 'reflect-metadata';
import {
bindTo,
Container,
DependencyNotFoundError,
register,
Registration as R,
scope,
scopeAccess,
singleton,
} from 'ts-ioc-container';
/**
* User Management Domain - Visibility Control
*
* Some services should only be accessible in specific scopes:
* - AdminService: Only accessible in admin routes
* - AuditLogger: Only accessible at application level (not per-request)
* - DebugService: Only accessible in development environment
*
* scopeAccess() controls VISIBILITY - whether a registered service
* can be resolved from a particular scope.
*
* This provides security-by-design:
* - Prevents accidental access to sensitive services
* - Enforces architectural boundaries
* - Catches misuse at resolution time (not runtime)
*/
describe('Visibility', function () {
it('should restrict admin services to admin routes only', () => {
// UserManagementService can delete users - admin only!
@register(
bindTo('IUserManagement'),
scope((s) => s.hasTag('application')), // Registered at app level
singleton(),
// Only accessible from admin scope, not regular request scope
scopeAccess(({ invocationScope }) => invocationScope.hasTag('admin')),
)
class UserManagementService {
deleteUser(userId: string): string {
return `Deleted user ${userId}`;
}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(UserManagementService));
// Admin route scope
const adminScope = appContainer.createScope({ tags: ['request', 'admin'] });
// Regular user route scope
const userScope = appContainer.createScope({ tags: ['request', 'user'] });
// Admin can access UserManagementService
const adminService = adminScope.resolve<UserManagementService>('IUserManagement');
expect(adminService.deleteUser('user-123')).toBe('Deleted user user-123');
// Regular users cannot access it - security enforced at DI level
expect(() => userScope.resolve('IUserManagement')).toThrowError(DependencyNotFoundError);
});
it('should restrict application-level services from request scope', () => {
// AuditLogger should only be used at application initialization
// Not from request handlers (to prevent log corruption from concurrent access)
@register(
bindTo('IAuditLogger'),
scope((s) => s.hasTag('application')),
singleton(),
// Only accessible from the scope where it was registered
scopeAccess(({ invocationScope, providerScope }) => invocationScope === providerScope),
)
class AuditLogger {
log(message: string): string {
return `AUDIT: ${message}`;
}
}
const appContainer = new Container({ tags: ['application'] }).addRegistration(R.fromClass(AuditLogger));
const requestScope = appContainer.createScope({ tags: ['request'] });
// Application can use AuditLogger (for startup logging)
expect(appContainer.resolve<AuditLogger>('IAuditLogger').log('App started')).toBe('AUDIT: App started');
// Request handlers cannot access it directly
expect(() => requestScope.resolve('IAuditLogger')).toThrowError(DependencyNotFoundError);
});
});
Use Cases
- UserManagementService - Only accessible from admin routes
- AuditLogger - Only accessible at application level (not per-request)
- DebugService - Only accessible in development environment
- InternalApiClient - Only accessible from backend services, not controllers
Alias
Aliases allow you to group multiple registrations under a common identifier. This is perfect for plugin systems, middleware, or any scenario where you need to resolve multiple implementations of the same interface.
import 'reflect-metadata';
import {
bindTo,
Container,
DependencyNotFoundError,
inject,
register,
Registration as R,
scope,
select as s,
} from 'ts-ioc-container';
/**
* User Management Domain - Alias Pattern (Multiple Implementations)
*
* Aliases allow multiple services to be registered under the same key.
* This is useful for:
* - Plugin systems (multiple notification channels)
* - Strategy pattern (multiple authentication providers)
* - Event handlers (multiple listeners for same event)
*
* Example: NotificationService with Email, SMS, and Push implementations
*/
describe('alias', () => {
// All notification services share this alias
const INotificationChannel = 'INotificationChannel';
const notificationChannel = register(bindTo(s.alias(INotificationChannel)));
interface INotificationChannel {
send(userId: string, message: string): void;
getDeliveredMessages(): string[];
}
// Email notification - always available
@notificationChannel
class EmailNotifier implements INotificationChannel {
private delivered: string[] = [];
send(userId: string, message: string): void {
this.delivered.push(`EMAIL to ${userId}: ${message}`);
}
getDeliveredMessages(): string[] {
return this.delivered;
}
}
// SMS notification - for urgent messages
@notificationChannel
class SmsNotifier implements INotificationChannel {
private delivered: string[] = [];
send(userId: string, message: string): void {
this.delivered.push(`SMS to ${userId}: ${message}`);
}
getDeliveredMessages(): string[] {
return this.delivered;
}
}
it('should notify through all channels', () => {
// NotificationManager broadcasts to ALL registered channels
class NotificationManager {
constructor(@inject(s.alias(INotificationChannel)) private channels: INotificationChannel[]) {}
notifyUser(userId: string, message: string): void {
for (const channel of this.channels) {
channel.send(userId, message);
}
}
getChannelCount(): number {
return this.channels.length;
}
}
const container = new Container()
.addRegistration(R.fromClass(EmailNotifier))
.addRegistration(R.fromClass(SmsNotifier));
const manager = container.resolve(NotificationManager);
manager.notifyUser('user-123', 'Your password was reset');
// Both channels received the message
expect(manager.getChannelCount()).toBe(2);
});
it('should resolve single implementation by alias', () => {
// Sometimes you only need one implementation (e.g., primary email service)
@register(bindTo(s.alias('IPrimaryNotifier')))
class PrimaryEmailNotifier {
readonly type = 'email';
}
const container = new Container().addRegistration(R.fromClass(PrimaryEmailNotifier));
// resolveOneByAlias returns first matching implementation
const notifier = container.resolveOneByAlias<PrimaryEmailNotifier>('IPrimaryNotifier');
expect(notifier.type).toBe('email');
// Direct key resolution fails - only alias is registered
expect(() => container.resolve('IPrimaryNotifier')).toThrowError(DependencyNotFoundError);
});
it('should use different implementations per scope', () => {
// Development: Console logger for easy debugging
@register(bindTo(s.alias('ILogger')), scope((s) => s.hasTag('development')))
class ConsoleLogger {
readonly type = 'console';
}
// Production: Database logger for audit trail
@register(bindTo(s.alias('ILogger')), scope((s) => s.hasTag('production')))
class DatabaseLogger {
readonly type = 'database';
}
// Development environment
const devContainer = new Container({ tags: ['development'] })
.addRegistration(R.fromClass(ConsoleLogger))
.addRegistration(R.fromClass(DatabaseLogger));
// Production environment
const prodContainer = new Container({ tags: ['production'] })
.addRegistration(R.fromClass(ConsoleLogger))
.addRegistration(R.fromClass(DatabaseLogger));
const devLogger = devContainer.resolveOneByAlias<ConsoleLogger | DatabaseLogger>('ILogger');
const prodLogger = prodContainer.resolveOneByAlias<ConsoleLogger | DatabaseLogger>('ILogger');
expect(devLogger.type).toBe('console');
expect(prodLogger.type).toBe('database');
});
});
import 'reflect-metadata';
import {
bindTo,
Container,
DependencyNotFoundError,
inject,
register,
Registration as R,
scope,
select as s,
} from 'ts-ioc-container';
/**
* User Management Domain - Alias Pattern (Multiple Implementations)
*
* Aliases allow multiple services to be registered under the same key.
* This is useful for:
* - Plugin systems (multiple notification channels)
* - Strategy pattern (multiple authentication providers)
* - Event handlers (multiple listeners for same event)
*
* Example: NotificationService with Email, SMS, and Push implementations
*/
describe('alias', () => {
// All notification services share this alias
const INotificationChannel = 'INotificationChannel';
const notificationChannel = register(bindTo(s.alias(INotificationChannel)));
interface INotificationChannel {
send(userId: string, message: string): void;
getDeliveredMessages(): string[];
}
// Email notification - always available
@notificationChannel
class EmailNotifier implements INotificationChannel {
private delivered: string[] = [];
send(userId: string, message: string): void {
this.delivered.push(`EMAIL to ${userId}: ${message}`);
}
getDeliveredMessages(): string[] {
return this.delivered;
}
}
// SMS notification - for urgent messages
@notificationChannel
class SmsNotifier implements INotificationChannel {
private delivered: string[] = [];
send(userId: string, message: string): void {
this.delivered.push(`SMS to ${userId}: ${message}`);
}
getDeliveredMessages(): string[] {
return this.delivered;
}
}
it('should notify through all channels', () => {
// NotificationManager broadcasts to ALL registered channels
class NotificationManager {
constructor(@inject(s.alias(INotificationChannel)) private channels: INotificationChannel[]) {}
notifyUser(userId: string, message: string): void {
for (const channel of this.channels) {
channel.send(userId, message);
}
}
getChannelCount(): number {
return this.channels.length;
}
}
const container = new Container()
.addRegistration(R.fromClass(EmailNotifier))
.addRegistration(R.fromClass(SmsNotifier));
const manager = container.resolve(NotificationManager);
manager.notifyUser('user-123', 'Your password was reset');
// Both channels received the message
expect(manager.getChannelCount()).toBe(2);
});
it('should resolve single implementation by alias', () => {
// Sometimes you only need one implementation (e.g., primary email service)
@register(bindTo(s.alias('IPrimaryNotifier')))
class PrimaryEmailNotifier {
readonly type = 'email';
}
const container = new Container().addRegistration(R.fromClass(PrimaryEmailNotifier));
// resolveOneByAlias returns first matching implementation
const notifier = container.resolveOneByAlias<PrimaryEmailNotifier>('IPrimaryNotifier');
expect(notifier.type).toBe('email');
// Direct key resolution fails - only alias is registered
expect(() => container.resolve('IPrimaryNotifier')).toThrowError(DependencyNotFoundError);
});
it('should use different implementations per scope', () => {
// Development: Console logger for easy debugging
@register(bindTo(s.alias('ILogger')), scope((s) => s.hasTag('development')))
class ConsoleLogger {
readonly type = 'console';
}
// Production: Database logger for audit trail
@register(bindTo(s.alias('ILogger')), scope((s) => s.hasTag('production')))
class DatabaseLogger {
readonly type = 'database';
}
// Development environment
const devContainer = new Container({ tags: ['development'] })
.addRegistration(R.fromClass(ConsoleLogger))
.addRegistration(R.fromClass(DatabaseLogger));
// Production environment
const prodContainer = new Container({ tags: ['production'] })
.addRegistration(R.fromClass(ConsoleLogger))
.addRegistration(R.fromClass(DatabaseLogger));
const devLogger = devContainer.resolveOneByAlias<ConsoleLogger | DatabaseLogger>('ILogger');
const prodLogger = prodContainer.resolveOneByAlias<ConsoleLogger | DatabaseLogger>('ILogger');
expect(devLogger.type).toBe('console');
expect(prodLogger.type).toBe('database');
});
});
Use Cases
- NotificationChannels - Email, SMS, Push notifications under one alias
- AuthenticationProviders - OAuth, LDAP, local auth strategies
- ValidationRules - Multiple validators for a form field
- EventListeners - Multiple handlers for user registration events
Decorator
The decorator pattern allows you to wrap instances with additional functionality without modifying the original class. This is useful for cross-cutting concerns like logging, caching, or transaction management.
import {
bindTo,
Container,
decorate,
type IContainer,
inject,
register,
Registration as R,
select as s,
singleton,
} from 'ts-ioc-container';
/**
* User Management Domain - Decorator Pattern
*
* The decorator pattern wraps a service with additional behavior:
* - Logging: Log all repository operations for audit
* - Caching: Cache results of expensive operations
* - Retry: Automatically retry failed operations
* - Validation: Validate inputs before processing
*
* In DI, decorators are applied at registration time, so consumers
* get the decorated version without knowing about the decoration.
*
* This example shows a TodoRepository decorated with logging -
* every save operation is automatically logged.
*/
describe('Decorator Pattern', () => {
// Singleton logger collects all log entries
@register(singleton())
class Logger {
private logs: string[] = [];
log(message: string) {
this.logs.push(message);
}
printLogs() {
return this.logs.join(',');
}
}
interface IRepository {
save(item: Todo): Promise<void>;
}
interface Todo {
id: string;
text: string;
}
// Decorator: Wraps any IRepository with logging behavior
class LoggingRepository implements IRepository {
constructor(
private repository: IRepository,
@inject(s.token('Logger').lazy()) private logger: Logger,
) {}
async save(item: Todo): Promise<void> {
// Log the operation
this.logger.log(item.id);
// Delegate to the wrapped repository
return this.repository.save(item);
}
}
// Decorator factory - creates LoggingRepository wrapping the original
const withLogging = (repository: IRepository, scope: IContainer) =>
scope.resolve(LoggingRepository, { args: [repository] });
// TodoRepository is automatically decorated with logging
@register(bindTo('IRepository'), decorate(withLogging))
class TodoRepository implements IRepository {
async save(item: Todo): Promise<void> {
// Actual database save logic would go here
}
}
class App {
constructor(@inject('IRepository') public repository: IRepository) {}
async run() {
await this.repository.save({ id: '1', text: 'Buy groceries' });
await this.repository.save({ id: '2', text: 'Walk the dog' });
}
}
function createAppContainer() {
return new Container({ tags: ['application'] })
.addRegistration(R.fromClass(TodoRepository))
.addRegistration(R.fromClass(Logger));
}
it('should automatically log all repository operations via decorator', async () => {
const container = createAppContainer();
const app = container.resolve(App);
const logger = container.resolve<Logger>('Logger');
// App uses repository normally - unaware of logging decorator
await app.run();
// All operations were logged transparently
expect(logger.printLogs()).toBe('1,2');
});
});
import {
bindTo,
Container,
decorate,
type IContainer,
inject,
register,
Registration as R,
select as s,
singleton,
} from 'ts-ioc-container';
/**
* User Management Domain - Decorator Pattern
*
* The decorator pattern wraps a service with additional behavior:
* - Logging: Log all repository operations for audit
* - Caching: Cache results of expensive operations
* - Retry: Automatically retry failed operations
* - Validation: Validate inputs before processing
*
* In DI, decorators are applied at registration time, so consumers
* get the decorated version without knowing about the decoration.
*
* This example shows a TodoRepository decorated with logging -
* every save operation is automatically logged.
*/
describe('Decorator Pattern', () => {
// Singleton logger collects all log entries
@register(singleton())
class Logger {
private logs: string[] = [];
log(message: string) {
this.logs.push(message);
}
printLogs() {
return this.logs.join(',');
}
}
interface IRepository {
save(item: Todo): Promise<void>;
}
interface Todo {
id: string;
text: string;
}
// Decorator: Wraps any IRepository with logging behavior
class LoggingRepository implements IRepository {
constructor(
private repository: IRepository,
@inject(s.token('Logger').lazy()) private logger: Logger,
) {}
async save(item: Todo): Promise<void> {
// Log the operation
this.logger.log(item.id);
// Delegate to the wrapped repository
return this.repository.save(item);
}
}
// Decorator factory - creates LoggingRepository wrapping the original
const withLogging = (repository: IRepository, scope: IContainer) =>
scope.resolve(LoggingRepository, { args: [repository] });
// TodoRepository is automatically decorated with logging
@register(bindTo('IRepository'), decorate(withLogging))
class TodoRepository implements IRepository {
async save(item: Todo): Promise<void> {
// Actual database save logic would go here
}
}
class App {
constructor(@inject('IRepository') public repository: IRepository) {}
async run() {
await this.repository.save({ id: '1', text: 'Buy groceries' });
await this.repository.save({ id: '2', text: 'Walk the dog' });
}
}
function createAppContainer() {
return new Container({ tags: ['application'] })
.addRegistration(R.fromClass(TodoRepository))
.addRegistration(R.fromClass(Logger));
}
it('should automatically log all repository operations via decorator', async () => {
const container = createAppContainer();
const app = container.resolve(App);
const logger = container.resolve<Logger>('Logger');
// App uses repository normally - unaware of logging decorator
await app.run();
// All operations were logged transparently
expect(logger.printLogs()).toBe('1,2');
});
});
Use Cases
- Cross-cutting concerns (logging, caching, transactions)
- AOP (Aspect-Oriented Programming)
- Wrapping services with additional behavior
- Proxy patterns
Lazy Loading
Lazy providers defer instantiation until the dependency is actually accessed. This improves startup performance by avoiding initialization of expensive services until they’re needed.
Use Cases
- Performance optimization - Defer expensive initialization (database connections, SMTP, external APIs)
- Conditional features - Don’t initialize features that may not be used
- Circular dependencies - Break dependency cycles
- Memory optimization - Reduce memory footprint for optional services
Two Ways to Use Lazy Loading
1. With @register Decorator
Use the lazy() registerPipe directly in the decorator:
import { register, bindTo, lazy, singleton } from 'ts-ioc-container';
// Expensive service - deferred initialization
@register(bindTo('AnalyticsService'), lazy(), singleton())
class AnalyticsService {
constructor() {
// Expensive: Connects to analytics API
console.log('Analytics initialized');
}
trackEvent(event: string): void {
// Track analytics event
}
}import { register, bindTo, lazy, singleton } from 'ts-ioc-container';
// Expensive service - deferred initialization
@register(bindTo('AnalyticsService'), lazy(), singleton())
class AnalyticsService {
constructor() {
// Expensive: Connects to analytics API
console.log('Analytics initialized');
}
trackEvent(event: string): void {
// Track analytics event
}
}2. With Provider Pipe
Use Provider.fromClass() directly with the lazy() helper:
import { Provider, lazy, singleton } from 'ts-ioc-container';
// Pure provider with lazy loading
const emailProvider = Provider.fromClass(EmailService)
.pipe(lazy(), singleton());
// Register the provider
const container = new Container();
container.register('EmailService', emailProvider);import { Provider, lazy, singleton } from 'ts-ioc-container';
// Pure provider with lazy loading
const emailProvider = Provider.fromClass(EmailService)
.pipe(lazy(), singleton());
// Register the provider
const container = new Container();
container.register('EmailService', emailProvider);Complete Examples
import 'reflect-metadata';
import { bindTo, Container, inject, lazy, Provider, register, Registration as R, singleton } from 'ts-ioc-container';
/**
* Lazy Loading with registerPipe
*
* The lazy() registerPipe can be used in two ways:
* 1. With @register decorator - lazy()
* 2. Directly on provider - provider.lazy()
*
* Both approaches defer instantiation until first access,
* improving startup time and memory usage.
*/
describe('lazy registerPipe', () => {
// Track initialization for testing
const initLog: string[] = [];
beforeEach(() => {
initLog.length = 0;
});
/**
* Example 1: Using lazy() with @register decorator
*
* The lazy() registerPipe defers service instantiation until first use.
* Perfect for expensive services that may not always be needed.
*/
describe('with @register decorator', () => {
// Database connection pool - expensive to initialize
@register(bindTo('DatabasePool'), singleton())
class DatabasePool {
constructor() {
initLog.push('DatabasePool initialized');
}
query(sql: string): string[] {
return [`Results for: ${sql}`];
}
}
// Analytics service - expensive, but only used occasionally
@register(bindTo('AnalyticsService'), lazy(), singleton())
class AnalyticsService {
constructor(@inject('DatabasePool') private db: DatabasePool) {
initLog.push('AnalyticsService initialized');
}
trackEvent(event: string): void {
this.db.query(`INSERT INTO events VALUES ('${event}')`);
}
generateReport(): string {
return 'Analytics Report';
}
}
// Application service - always used
class AppService {
constructor(@inject('AnalyticsService') public analytics: AnalyticsService) {
initLog.push('AppService initialized');
}
handleRequest(path: string): void {
// Most requests don't need analytics
if (path.includes('/admin')) {
// Only admin requests use analytics
this.analytics.trackEvent(`Admin access: ${path}`);
}
}
}
it('should defer AnalyticsService initialization until first access', () => {
const container = new Container()
.addRegistration(R.fromClass(DatabasePool))
.addRegistration(R.fromClass(AnalyticsService))
.addRegistration(R.fromClass(AppService));
// Resolve AppService
const app = container.resolve<AppService>(AppService);
// AppService is initialized, but AnalyticsService is NOT (it's lazy)
// DatabasePool is also not initialized because AnalyticsService hasn't been accessed
expect(initLog).toEqual(['AppService initialized']);
// Handle non-admin request - analytics not used
app.handleRequest('/api/users');
expect(initLog).toEqual(['AppService initialized']);
});
it('should initialize lazy service when first accessed', () => {
const container = new Container()
.addRegistration(R.fromClass(DatabasePool))
.addRegistration(R.fromClass(AnalyticsService))
.addRegistration(R.fromClass(AppService));
const app = container.resolve<AppService>(AppService);
// Handle admin request - now analytics IS used
app.handleRequest('/admin/dashboard');
// AnalyticsService was initialized on first access (DatabasePool too, as a dependency)
expect(initLog).toEqual(['AppService initialized', 'DatabasePool initialized', 'AnalyticsService initialized']);
});
it('should create only one instance even with multiple accesses', () => {
const container = new Container()
.addRegistration(R.fromClass(DatabasePool))
.addRegistration(R.fromClass(AnalyticsService))
.addRegistration(R.fromClass(AppService));
const app = container.resolve<AppService>(AppService);
// Access analytics multiple times
app.handleRequest('/admin/dashboard');
app.analytics.generateReport();
app.analytics.trackEvent('test');
// AnalyticsService initialized only once (singleton + lazy)
const analyticsCount = initLog.filter((msg) => msg === 'AnalyticsService initialized').length;
expect(analyticsCount).toBe(1);
});
});
/**
* Example 2: Using lazy() directly on provider
*
* For manual registration, call .lazy() on the provider pipe.
* This gives fine-grained control over lazy loading per dependency.
*/
describe('with pure provider', () => {
// Email service - expensive SMTP connection
class EmailService {
constructor() {
initLog.push('EmailService initialized - SMTP connected');
}
send(to: string, subject: string): string {
return `Email sent to ${to}: ${subject}`;
}
}
// SMS service - expensive gateway connection
class SmsService {
constructor() {
initLog.push('SmsService initialized - Gateway connected');
}
send(to: string, message: string): string {
return `SMS sent to ${to}: ${message}`;
}
}
// Notification service - uses email and SMS, but maybe not both
class NotificationService {
constructor(
@inject('EmailService') public email: EmailService,
@inject('SmsService') public sms: SmsService,
) {
initLog.push('NotificationService initialized');
}
notifyByEmail(user: string, message: string): string {
return this.email.send(user, message);
}
notifyBySms(phone: string, message: string): string {
return this.sms.send(phone, message);
}
}
it('should allow selective lazy loading - email lazy, SMS eager', () => {
const container = new Container()
// EmailService is lazy - won't connect to SMTP until used
.addRegistration(
R.fromClass(EmailService)
.bindToKey('EmailService')
.pipe(singleton(), (p) => p.lazy()),
)
// SmsService is eager - connects to gateway immediately
.addRegistration(R.fromClass(SmsService).bindToKey('SmsService').pipe(singleton()))
.addRegistration(R.fromClass(NotificationService));
// Resolve NotificationService
const notifications = container.resolve<NotificationService>(NotificationService);
// SmsService initialized immediately (eager)
// EmailService NOT initialized yet (lazy)
expect(initLog).toEqual(['SmsService initialized - Gateway connected', 'NotificationService initialized']);
// Send SMS - already initialized
notifications.notifyBySms('555-1234', 'Test');
expect(initLog).toEqual(['SmsService initialized - Gateway connected', 'NotificationService initialized']);
});
it('should initialize lazy email service when first accessed', () => {
const container = new Container()
.addRegistration(
R.fromClass(EmailService)
.bindToKey('EmailService')
.pipe(singleton(), (p) => p.lazy()),
)
.addRegistration(R.fromClass(SmsService).bindToKey('SmsService').pipe(singleton()))
.addRegistration(R.fromClass(NotificationService));
const notifications = container.resolve<NotificationService>(NotificationService);
// Send email - NOW EmailService is initialized
const result = notifications.notifyByEmail('user@example.com', 'Welcome!');
expect(result).toBe('Email sent to user@example.com: Welcome!');
expect(initLog).toContain('EmailService initialized - SMTP connected');
});
it('should work with multiple lazy providers', () => {
const container = new Container()
// Both services are lazy
.addRegistration(
R.fromClass(EmailService)
.bindToKey('EmailService')
.pipe(singleton(), (p) => p.lazy()),
)
.addRegistration(
R.fromClass(SmsService)
.bindToKey('SmsService')
.pipe(singleton(), (p) => p.lazy()),
)
.addRegistration(R.fromClass(NotificationService));
const notifications = container.resolve<NotificationService>(NotificationService);
// Neither service initialized yet
expect(initLog).toEqual(['NotificationService initialized']);
// Use SMS - only SMS initialized
notifications.notifyBySms('555-1234', 'Test');
expect(initLog).toEqual(['NotificationService initialized', 'SmsService initialized - Gateway connected']);
// Use Email - now Email initialized
notifications.notifyByEmail('user@example.com', 'Test');
expect(initLog).toEqual([
'NotificationService initialized',
'SmsService initialized - Gateway connected',
'EmailService initialized - SMTP connected',
]);
});
});
/**
* Example 3: Pure Provider usage (without Registration)
*
* Use Provider.fromClass() directly with lazy() for maximum flexibility.
*/
describe('with pure Provider', () => {
class CacheService {
constructor() {
initLog.push('CacheService initialized - Redis connected');
}
get(key: string): string | null {
return `cached:${key}`;
}
}
class ApiService {
constructor(@inject('CacheService') private cache: CacheService) {
initLog.push('ApiService initialized');
}
fetchData(id: string): string {
const cached = this.cache.get(id);
return cached || `fresh:${id}`;
}
}
it('should use Provider.fromClass with lazy() helper', () => {
// Create pure provider with lazy loading
const cacheProvider = Provider.fromClass(CacheService).pipe(lazy(), singleton());
const container = new Container();
container.register('CacheService', cacheProvider);
container.addRegistration(R.fromClass(ApiService));
const api = container.resolve<ApiService>(ApiService);
// CacheService not initialized yet (lazy)
expect(initLog).toEqual(['ApiService initialized']);
// Access cache - NOW it's initialized
api.fetchData('user:1');
expect(initLog).toContain('CacheService initialized - Redis connected');
});
it('should allow importing lazy as named export', () => {
// Demonstrate that lazy() is imported from the library
const cacheProvider = Provider.fromClass(CacheService).pipe(lazy());
const container = new Container();
container.register('CacheService', cacheProvider);
const cache = container.resolve<CacheService>('CacheService');
// Not initialized until accessed
expect(initLog).toEqual([]);
cache.get('test');
expect(initLog).toEqual(['CacheService initialized - Redis connected']);
});
});
/**
* Example 4: Combining lazy with other pipes
*
* lazy() works seamlessly with other provider transformations.
*/
describe('combining with other pipes', () => {
class ConfigService {
constructor(
public apiUrl: string,
public timeout: number,
) {
initLog.push(`ConfigService initialized with ${apiUrl}`);
}
}
it('should combine lazy with args and singleton', () => {
const container = new Container().addRegistration(
R.fromClass(ConfigService)
.bindToKey('Config')
.pipe(
(p) => p.setArgs(() => ['https://api.example.com', 5000]),
(p) => p.lazy(),
)
.pipe(singleton()),
);
// Config not initialized yet
expect(initLog).toEqual([]);
// Resolve - still not initialized (lazy)
const config1 = container.resolve<ConfigService>('Config');
expect(initLog).toEqual([]);
// Access property - NOW initialized
const url = config1.apiUrl;
expect(url).toBe('https://api.example.com');
expect(initLog).toEqual(['ConfigService initialized with https://api.example.com']);
// Resolve again - same instance (singleton)
const config2 = container.resolve<ConfigService>('Config');
expect(config2).toBe(config1);
expect(initLog.length).toBe(1); // Still only one initialization
});
});
/**
* Example 5: Real-world use case - Resource Management
*
* Lazy loading is ideal for:
* - Database connections
* - File handles
* - External API clients
* - Report generators
*/
describe('real-world example - feature flags', () => {
class FeatureFlagService {
constructor() {
initLog.push('FeatureFlagService initialized');
}
isEnabled(feature: string): boolean {
return feature === 'premium';
}
}
@register(bindTo('PremiumFeature'), lazy(), singleton())
class PremiumFeature {
constructor() {
initLog.push('PremiumFeature initialized - expensive operation');
}
execute(): string {
return 'Premium feature executed';
}
}
class Application {
constructor(
@inject('FeatureFlagService') private flags: FeatureFlagService,
@inject('PremiumFeature') private premium: PremiumFeature,
) {
initLog.push('Application initialized');
}
handleRequest(feature: string): string {
if (this.flags.isEnabled(feature)) {
return this.premium.execute();
}
return 'Standard feature';
}
}
it('should not initialize premium features for standard users', () => {
const container = new Container()
.addRegistration(R.fromClass(FeatureFlagService).bindToKey('FeatureFlagService').pipe(singleton()))
.addRegistration(R.fromClass(PremiumFeature))
.addRegistration(R.fromClass(Application));
const app = container.resolve<Application>(Application);
// Standard request - premium feature not initialized
const result = app.handleRequest('standard');
expect(result).toBe('Standard feature');
expect(initLog).not.toContain('PremiumFeature initialized - expensive operation');
});
it('should initialize premium features only for premium users', () => {
const container = new Container()
.addRegistration(R.fromClass(FeatureFlagService).bindToKey('FeatureFlagService').pipe(singleton()))
.addRegistration(R.fromClass(PremiumFeature))
.addRegistration(R.fromClass(Application));
const app = container.resolve<Application>(Application);
// Premium request - NOW premium feature is initialized
const result = app.handleRequest('premium');
expect(result).toBe('Premium feature executed');
expect(initLog).toContain('PremiumFeature initialized - expensive operation');
});
});
});
import 'reflect-metadata';
import { bindTo, Container, inject, lazy, Provider, register, Registration as R, singleton } from 'ts-ioc-container';
/**
* Lazy Loading with registerPipe
*
* The lazy() registerPipe can be used in two ways:
* 1. With @register decorator - lazy()
* 2. Directly on provider - provider.lazy()
*
* Both approaches defer instantiation until first access,
* improving startup time and memory usage.
*/
describe('lazy registerPipe', () => {
// Track initialization for testing
const initLog: string[] = [];
beforeEach(() => {
initLog.length = 0;
});
/**
* Example 1: Using lazy() with @register decorator
*
* The lazy() registerPipe defers service instantiation until first use.
* Perfect for expensive services that may not always be needed.
*/
describe('with @register decorator', () => {
// Database connection pool - expensive to initialize
@register(bindTo('DatabasePool'), singleton())
class DatabasePool {
constructor() {
initLog.push('DatabasePool initialized');
}
query(sql: string): string[] {
return [`Results for: ${sql}`];
}
}
// Analytics service - expensive, but only used occasionally
@register(bindTo('AnalyticsService'), lazy(), singleton())
class AnalyticsService {
constructor(@inject('DatabasePool') private db: DatabasePool) {
initLog.push('AnalyticsService initialized');
}
trackEvent(event: string): void {
this.db.query(`INSERT INTO events VALUES ('${event}')`);
}
generateReport(): string {
return 'Analytics Report';
}
}
// Application service - always used
class AppService {
constructor(@inject('AnalyticsService') public analytics: AnalyticsService) {
initLog.push('AppService initialized');
}
handleRequest(path: string): void {
// Most requests don't need analytics
if (path.includes('/admin')) {
// Only admin requests use analytics
this.analytics.trackEvent(`Admin access: ${path}`);
}
}
}
it('should defer AnalyticsService initialization until first access', () => {
const container = new Container()
.addRegistration(R.fromClass(DatabasePool))
.addRegistration(R.fromClass(AnalyticsService))
.addRegistration(R.fromClass(AppService));
// Resolve AppService
const app = container.resolve<AppService>(AppService);
// AppService is initialized, but AnalyticsService is NOT (it's lazy)
// DatabasePool is also not initialized because AnalyticsService hasn't been accessed
expect(initLog).toEqual(['AppService initialized']);
// Handle non-admin request - analytics not used
app.handleRequest('/api/users');
expect(initLog).toEqual(['AppService initialized']);
});
it('should initialize lazy service when first accessed', () => {
const container = new Container()
.addRegistration(R.fromClass(DatabasePool))
.addRegistration(R.fromClass(AnalyticsService))
.addRegistration(R.fromClass(AppService));
const app = container.resolve<AppService>(AppService);
// Handle admin request - now analytics IS used
app.handleRequest('/admin/dashboard');
// AnalyticsService was initialized on first access (DatabasePool too, as a dependency)
expect(initLog).toEqual(['AppService initialized', 'DatabasePool initialized', 'AnalyticsService initialized']);
});
it('should create only one instance even with multiple accesses', () => {
const container = new Container()
.addRegistration(R.fromClass(DatabasePool))
.addRegistration(R.fromClass(AnalyticsService))
.addRegistration(R.fromClass(AppService));
const app = container.resolve<AppService>(AppService);
// Access analytics multiple times
app.handleRequest('/admin/dashboard');
app.analytics.generateReport();
app.analytics.trackEvent('test');
// AnalyticsService initialized only once (singleton + lazy)
const analyticsCount = initLog.filter((msg) => msg === 'AnalyticsService initialized').length;
expect(analyticsCount).toBe(1);
});
});
/**
* Example 2: Using lazy() directly on provider
*
* For manual registration, call .lazy() on the provider pipe.
* This gives fine-grained control over lazy loading per dependency.
*/
describe('with pure provider', () => {
// Email service - expensive SMTP connection
class EmailService {
constructor() {
initLog.push('EmailService initialized - SMTP connected');
}
send(to: string, subject: string): string {
return `Email sent to ${to}: ${subject}`;
}
}
// SMS service - expensive gateway connection
class SmsService {
constructor() {
initLog.push('SmsService initialized - Gateway connected');
}
send(to: string, message: string): string {
return `SMS sent to ${to}: ${message}`;
}
}
// Notification service - uses email and SMS, but maybe not both
class NotificationService {
constructor(
@inject('EmailService') public email: EmailService,
@inject('SmsService') public sms: SmsService,
) {
initLog.push('NotificationService initialized');
}
notifyByEmail(user: string, message: string): string {
return this.email.send(user, message);
}
notifyBySms(phone: string, message: string): string {
return this.sms.send(phone, message);
}
}
it('should allow selective lazy loading - email lazy, SMS eager', () => {
const container = new Container()
// EmailService is lazy - won't connect to SMTP until used
.addRegistration(
R.fromClass(EmailService)
.bindToKey('EmailService')
.pipe(singleton(), (p) => p.lazy()),
)
// SmsService is eager - connects to gateway immediately
.addRegistration(R.fromClass(SmsService).bindToKey('SmsService').pipe(singleton()))
.addRegistration(R.fromClass(NotificationService));
// Resolve NotificationService
const notifications = container.resolve<NotificationService>(NotificationService);
// SmsService initialized immediately (eager)
// EmailService NOT initialized yet (lazy)
expect(initLog).toEqual(['SmsService initialized - Gateway connected', 'NotificationService initialized']);
// Send SMS - already initialized
notifications.notifyBySms('555-1234', 'Test');
expect(initLog).toEqual(['SmsService initialized - Gateway connected', 'NotificationService initialized']);
});
it('should initialize lazy email service when first accessed', () => {
const container = new Container()
.addRegistration(
R.fromClass(EmailService)
.bindToKey('EmailService')
.pipe(singleton(), (p) => p.lazy()),
)
.addRegistration(R.fromClass(SmsService).bindToKey('SmsService').pipe(singleton()))
.addRegistration(R.fromClass(NotificationService));
const notifications = container.resolve<NotificationService>(NotificationService);
// Send email - NOW EmailService is initialized
const result = notifications.notifyByEmail('user@example.com', 'Welcome!');
expect(result).toBe('Email sent to user@example.com: Welcome!');
expect(initLog).toContain('EmailService initialized - SMTP connected');
});
it('should work with multiple lazy providers', () => {
const container = new Container()
// Both services are lazy
.addRegistration(
R.fromClass(EmailService)
.bindToKey('EmailService')
.pipe(singleton(), (p) => p.lazy()),
)
.addRegistration(
R.fromClass(SmsService)
.bindToKey('SmsService')
.pipe(singleton(), (p) => p.lazy()),
)
.addRegistration(R.fromClass(NotificationService));
const notifications = container.resolve<NotificationService>(NotificationService);
// Neither service initialized yet
expect(initLog).toEqual(['NotificationService initialized']);
// Use SMS - only SMS initialized
notifications.notifyBySms('555-1234', 'Test');
expect(initLog).toEqual(['NotificationService initialized', 'SmsService initialized - Gateway connected']);
// Use Email - now Email initialized
notifications.notifyByEmail('user@example.com', 'Test');
expect(initLog).toEqual([
'NotificationService initialized',
'SmsService initialized - Gateway connected',
'EmailService initialized - SMTP connected',
]);
});
});
/**
* Example 3: Pure Provider usage (without Registration)
*
* Use Provider.fromClass() directly with lazy() for maximum flexibility.
*/
describe('with pure Provider', () => {
class CacheService {
constructor() {
initLog.push('CacheService initialized - Redis connected');
}
get(key: string): string | null {
return `cached:${key}`;
}
}
class ApiService {
constructor(@inject('CacheService') private cache: CacheService) {
initLog.push('ApiService initialized');
}
fetchData(id: string): string {
const cached = this.cache.get(id);
return cached || `fresh:${id}`;
}
}
it('should use Provider.fromClass with lazy() helper', () => {
// Create pure provider with lazy loading
const cacheProvider = Provider.fromClass(CacheService).pipe(lazy(), singleton());
const container = new Container();
container.register('CacheService', cacheProvider);
container.addRegistration(R.fromClass(ApiService));
const api = container.resolve<ApiService>(ApiService);
// CacheService not initialized yet (lazy)
expect(initLog).toEqual(['ApiService initialized']);
// Access cache - NOW it's initialized
api.fetchData('user:1');
expect(initLog).toContain('CacheService initialized - Redis connected');
});
it('should allow importing lazy as named export', () => {
// Demonstrate that lazy() is imported from the library
const cacheProvider = Provider.fromClass(CacheService).pipe(lazy());
const container = new Container();
container.register('CacheService', cacheProvider);
const cache = container.resolve<CacheService>('CacheService');
// Not initialized until accessed
expect(initLog).toEqual([]);
cache.get('test');
expect(initLog).toEqual(['CacheService initialized - Redis connected']);
});
});
/**
* Example 4: Combining lazy with other pipes
*
* lazy() works seamlessly with other provider transformations.
*/
describe('combining with other pipes', () => {
class ConfigService {
constructor(
public apiUrl: string,
public timeout: number,
) {
initLog.push(`ConfigService initialized with ${apiUrl}`);
}
}
it('should combine lazy with args and singleton', () => {
const container = new Container().addRegistration(
R.fromClass(ConfigService)
.bindToKey('Config')
.pipe(
(p) => p.setArgs(() => ['https://api.example.com', 5000]),
(p) => p.lazy(),
)
.pipe(singleton()),
);
// Config not initialized yet
expect(initLog).toEqual([]);
// Resolve - still not initialized (lazy)
const config1 = container.resolve<ConfigService>('Config');
expect(initLog).toEqual([]);
// Access property - NOW initialized
const url = config1.apiUrl;
expect(url).toBe('https://api.example.com');
expect(initLog).toEqual(['ConfigService initialized with https://api.example.com']);
// Resolve again - same instance (singleton)
const config2 = container.resolve<ConfigService>('Config');
expect(config2).toBe(config1);
expect(initLog.length).toBe(1); // Still only one initialization
});
});
/**
* Example 5: Real-world use case - Resource Management
*
* Lazy loading is ideal for:
* - Database connections
* - File handles
* - External API clients
* - Report generators
*/
describe('real-world example - feature flags', () => {
class FeatureFlagService {
constructor() {
initLog.push('FeatureFlagService initialized');
}
isEnabled(feature: string): boolean {
return feature === 'premium';
}
}
@register(bindTo('PremiumFeature'), lazy(), singleton())
class PremiumFeature {
constructor() {
initLog.push('PremiumFeature initialized - expensive operation');
}
execute(): string {
return 'Premium feature executed';
}
}
class Application {
constructor(
@inject('FeatureFlagService') private flags: FeatureFlagService,
@inject('PremiumFeature') private premium: PremiumFeature,
) {
initLog.push('Application initialized');
}
handleRequest(feature: string): string {
if (this.flags.isEnabled(feature)) {
return this.premium.execute();
}
return 'Standard feature';
}
}
it('should not initialize premium features for standard users', () => {
const container = new Container()
.addRegistration(R.fromClass(FeatureFlagService).bindToKey('FeatureFlagService').pipe(singleton()))
.addRegistration(R.fromClass(PremiumFeature))
.addRegistration(R.fromClass(Application));
const app = container.resolve<Application>(Application);
// Standard request - premium feature not initialized
const result = app.handleRequest('standard');
expect(result).toBe('Standard feature');
expect(initLog).not.toContain('PremiumFeature initialized - expensive operation');
});
it('should initialize premium features only for premium users', () => {
const container = new Container()
.addRegistration(R.fromClass(FeatureFlagService).bindToKey('FeatureFlagService').pipe(singleton()))
.addRegistration(R.fromClass(PremiumFeature))
.addRegistration(R.fromClass(Application));
const app = container.resolve<Application>(Application);
// Premium request - NOW premium feature is initialized
const result = app.handleRequest('premium');
expect(result).toBe('Premium feature executed');
expect(initLog).toContain('PremiumFeature initialized - expensive operation');
});
});
});
How Lazy Loading Works
When a lazy provider is resolved:
- Initial Resolution - A proxy is returned instead of the actual instance
- First Access - When any property or method is accessed, the real instance is created
- Subsequent Access - The proxy delegates all calls to the real instance
- Singleton Behavior - Combined with
singleton(), ensures only one instance is created
Combining with Other Pipes
Lazy loading works seamlessly with other provider features:
// Lazy + Singleton + Arguments
R.fromClass(ConfigService)
.pipe(
(p) => p.setArgs(() => ['https://api.example.com', 5000]),
(p) => p.lazy(),
singleton()
);
// Order matters - typically:
// 1. Arguments
// 2. Lazy
// 3. Singleton// Lazy + Singleton + Arguments
R.fromClass(ConfigService)
.pipe(
(p) => p.setArgs(() => ['https://api.example.com', 5000]),
(p) => p.lazy(),
singleton()
);
// Order matters - typically:
// 1. Arguments
// 2. Lazy
// 3. SingletonProvider Pipeline
Providers can be composed using the pipeline pattern. Each transformation wraps the provider with additional functionality:
fromClass Logger"] Args["Args Provider
args '/config.json'"] Singleton["Singleton Provider
cache instance"] Visibility["Visibility Provider
scopeAccess rule"] Lazy["Lazy Provider
deferred creation"] Base -->|pipe args| Args Args -->|pipe singleton| Singleton Singleton -->|pipe scopeAccess| Visibility Visibility -->|pipe lazy| Lazy Lazy -->|final| Final["Final Provider"] style Base fill:#e1e4e8 style Args fill:#c6e48b style Singleton fill:#7bc96f style Visibility fill:#239a3b style Lazy fill:#196127 style Final fill:#0366d6,color:#fff
Provider Composition Example
const provider = Provider.fromClass(Logger)
.pipe(args('/config.json')) // 1. Bind arguments
.pipe(singleton()) // 2. Cache instances
.pipe(scopeAccess(rule)) // 3. Control visibility
.pipe(lazy()); // 4. Defer creation
// Each pipe wraps the previous provider
// Final provider: Lazy(Visibility(Singleton(Args(Base))))
const provider = Provider.fromClass(Logger)
.pipe(args('/config.json')) // 1. Bind arguments
.pipe(singleton()) // 2. Cache instances
.pipe(scopeAccess(rule)) // 3. Control visibility
.pipe(lazy()); // 4. Defer creation
// Each pipe wraps the previous provider
// Final provider: Lazy(Visibility(Singleton(Args(Base))))
Decorator Pattern
The provider pipeline uses the Decorator pattern, allowing features to be added incrementally without modifying the base provider. Each pipe operation wraps the provider with additional functionality.
Composable Providers
The pipeline pattern allows providers to be composed from smaller pieces. This design:
- Keeps the base provider simple
- Allows features to be added incrementally
- Makes the system extensible
- Follows the Open/Closed Principle
You can also use the pipe method in registrations:
const container = new Container()
.addRegistration(
R.fromClass(ConfigService)
.pipe(args('/default/config.json'))
.pipe(singleton())
.pipe(scopeAccess(({ invocationScope }) => invocationScope.hasTag('admin')))
);const container = new Container()
.addRegistration(
R.fromClass(ConfigService)
.pipe(args('/default/config.json'))
.pipe(singleton())
.pipe(scopeAccess(({ invocationScope }) => invocationScope.hasTag('admin')))
);Performance Considerations
Singleton Caching
Singleton providers cache instances per scope, avoiding unnecessary object creation. The cache is implemented using a Map for O(1) lookup performance.
Lazy Loading
Lazy providers use JavaScript proxies to defer instantiation until access. This improves startup time by avoiding eager initialization of unused dependencies.
Provider Lookup
Providers are stored in a Map keyed by DependencyKey, providing O(1) lookup performance. Alias resolution uses a separate AliasMap for efficient multi-key lookups.
Custom Providers
Implement IProvider or extend ProviderDecorator to create custom providers:
class CustomProvider extends ProviderDecorator {
resolve(container: IContainer, options: ProviderOptions): T {
// Your custom resolution logic
}
}class CustomProvider extends ProviderDecorator {
resolve(container: IContainer, options: ProviderOptions): T {
// Your custom resolution logic
}
}Best Practices
- Use singletons for expensive resources - Database connections, HTTP clients, configuration objects
- Prefer argsFn for dynamic values - When arguments depend on other container values
- Use visibility for security - Restrict access to sensitive services
- Aliases for collections - Group related services together
- Decorators for cross-cutting concerns - Keep your core classes clean
- Lazy loading for performance - Defer expensive object creation