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.

Providers implement the Factory pattern, encapsulating the creation logic of dependencies. This allows for complex instantiation logic while keeping the container simple.

There are three main ways to create providers:

Singleton providers ensure only one instance is created per scope. This is perfect for services like loggers, database connections, or configuration managers.

TypeScript __tests__/readme/provider.spec.ts
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:

TypeScript
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

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.

TypeScript __tests__/readme/provider.spec.ts
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():

TypeScript
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 passed
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 passed

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.

TypeScript __tests__/readme/visibility.spec.ts
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

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.

TypeScript __tests__/readme/alias.spec.ts
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

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.

TypeScript __tests__/readme/decorate.spec.ts
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

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

Two Ways to Use Lazy Loading

1. With @register Decorator

Use the lazy() registerPipe directly in the decorator:

TypeScript
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:

TypeScript
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

TypeScript __tests__/readme/lazyPipe.spec.ts
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:

  1. Initial Resolution - A proxy is returned instead of the actual instance
  2. First Access - When any property or method is accessed, the real instance is created
  3. Subsequent Access - The proxy delegates all calls to the real instance
  4. Singleton Behavior - Combined with singleton(), ensures only one instance is created

Combining with Other Pipes

Lazy loading works seamlessly with other provider features:

TypeScript
// 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. Singleton

Providers can be composed using the pipeline pattern. Each transformation wraps the provider with additional functionality:

graph LR Base["Base Provider
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

TypeScript
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:

You can also use the pipe method in registrations:

TypeScript
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')))
);

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.

Implement IProvider or extend ProviderDecorator to create custom providers:

TypeScript
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
}
}