Container

The Container is the central component of the IoC system. It manages the lifecycle of dependencies, resolves them when requested, and maintains scoped instances. Understanding containers is essential for effective dependency injection.

Scopes allow you to create isolated dependency contexts. This is perfect for request-level isolation in web applications or feature-level separation. Each scope can have tags that control which providers are available.

Tagged Scopes

Tags allow you to control which providers are available in which scopes. This is useful for environment-specific configurations or feature flags.

TypeScript __tests__/readme/scopes.spec.ts
import {
  bindTo,
  Container,
  DependencyNotFoundError,
  type IContainer,
  inject,
  register,
  Registration as R,
  scope,
  select,
  singleton,
} from '../../lib';

@register(bindTo('ILogger'), scope((s) => s.hasTag('child')), singleton())
class Logger {}

describe('Scopes', function () {
  it('should resolve dependencies from scope', function () {
    const root = new Container({ tags: ['root'] }).addRegistration(R.fromClass(Logger));
    const child = root.createScope({ tags: ['child'] });

    expect(child.resolve('ILogger')).toBe(child.resolve('ILogger'));
    expect(() => root.resolve('ILogger')).toThrow(DependencyNotFoundError);
  });

  it('should inject new scope', function () {
    const root = new Container({ tags: ['root'] });

    class App {
      constructor(@inject(select.scope.create({ tags: ['child'] })) public scope: IContainer) {}
    }

    const app = root.resolve(App);

    expect(app.scope).not.toBe(root);
    expect(app.scope.hasTag('child')).toBe(true);
  });
});
import {
  bindTo,
  Container,
  DependencyNotFoundError,
  type IContainer,
  inject,
  register,
  Registration as R,
  scope,
  select,
  singleton,
} from '../../lib';

@register(bindTo('ILogger'), scope((s) => s.hasTag('child')), singleton())
class Logger {}

describe('Scopes', function () {
  it('should resolve dependencies from scope', function () {
    const root = new Container({ tags: ['root'] }).addRegistration(R.fromClass(Logger));
    const child = root.createScope({ tags: ['child'] });

    expect(child.resolve('ILogger')).toBe(child.resolve('ILogger'));
    expect(() => root.resolve('ILogger')).toThrow(DependencyNotFoundError);
  });

  it('should inject new scope', function () {
    const root = new Container({ tags: ['root'] });

    class App {
      constructor(@inject(select.scope.create({ tags: ['child'] })) public scope: IContainer) {}
    }

    const app = root.resolve(App);

    expect(app.scope).not.toBe(root);
    expect(app.scope.hasTag('child')).toBe(true);
  });
});

Scope Hierarchy

Containers can create child scopes, forming a tree structure. Each scope maintains its own provider registry and instance cache, but can fall back to parent scopes for resolution.

graph TD Root["Root Container
tags: 'root'"] Request1["Request Scope 1
tags: 'request', 'user:123'"] Request2["Request Scope 2
tags: 'request', 'user:456'"] Feature1["Feature Scope 1
tags: 'feature:admin'"] Feature2["Feature Scope 2
tags: 'feature:user'"] Root -->|createScope| Request1 Root -->|createScope| Request2 Request1 -->|createScope| Feature1 Request2 -->|createScope| Feature2 style Root fill:#0366d6,color:#fff style Request1 fill:#28a745,color:#fff style Request2 fill:#28a745,color:#fff style Feature1 fill:#ffc107,color:#000 style Feature2 fill:#ffc107,color:#000

Creating a Scope

When you create a new scope using createScope(), the container follows a specific process to set up the child scope. The new scope maintains a reference to its parent, allowing it to access parent registrations while maintaining its own isolated instance cache.

flowchart TD Create([Create new Container as Scope
with parent reference]) --> CopyHooks[Copy hooks
onConstruct, onDispose] CopyHooks --> GetRegs[Get all registrations
parent + current scope] GetRegs --> Apply[Apply registration to new scope] Apply --> AddScope[Add scope to
scopes list] AddScope --> Return([Return new scope]) style Create fill:#e1e4e8 style CopyHooks fill:#f1e05a style GetRegs fill:#c6e48b style AddScope fill:#7bc96f style Return fill:#0366d6,color:#fff

The scope creation process ensures that lifecycle hooks are properly inherited, all relevant registrations are available, and the new scope is properly registered in the parent's scope list for management and disposal.

Scope Resolution Strategy

When resolving a dependency, the container follows this strategy:

  1. Check if provider exists in current scope
  2. Check if provider has access in current scope (scope access rule)
  3. If not found, recursively check parent scopes
  4. If found in parent, check access from current scope
  5. Throw DependencyNotFoundError if not found anywhere

Dependency Resolution Flow

The following sequence diagram illustrates how a dependency is resolved from the container:

flowchart TD Start([Resolve dependency]) Start --> CheckConstructor{Is target
constructor?} CheckConstructor -->|Yes| Injector[Resolve constructor
from Injector] CheckConstructor -->|No| FindProvider[Find provider
by string or symbol] FindProvider --> Container{Provider exists
and has access
to current scope?} Container -->|Yes| Provider[Resolve dependency
from provider] Container -->|No| ResolveParent[Resolve dependency
from parent scope] style Start fill:#4CAF50,stroke:#2E7D32,stroke-width:3px,color:#fff style CheckConstructor fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#fff style Container fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#fff style Injector fill:#2196F3,stroke:#1565C0,stroke-width:2px,color:#fff style FindProvider fill:#9C27B0,stroke:#6A1B9A,stroke-width:2px,color:#fff style Provider fill:#00BCD4,stroke:#00838F,stroke-width:2px,color:#fff style ResolveParent fill:#FF5722,stroke:#BF360C,stroke-width:2px,color:#fff

The container tracks all instances it creates, allowing you to query and manage them. This is particularly useful for cleanup operations, debugging, or implementing custom lifecycle management.

Querying Instances

TypeScript __tests__/readme/instances.spec.ts
import { bindTo, Container, inject, register, Registration as R, select } from '../../lib';

describe('Instances', function () {
  @register(bindTo('ILogger'))
  class Logger {}

  it('should return injected instances', () => {
    class App {
      constructor(@inject(select.instances()) public loggers: Logger[]) {}
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromClass(Logger));
    const child = root.createScope({ tags: ['child'] });

    root.resolve('ILogger');
    child.resolve('ILogger');

    const rootApp = root.resolve(App);
    const childApp = child.resolve(App);

    expect(childApp.loggers.length).toBe(1);
    expect(rootApp.loggers.length).toBe(2);
  });

  it('should return only current scope instances', () => {
    class App {
      constructor(@inject(select.instances().cascade(false)) public loggers: Logger[]) {}
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromClass(Logger));
    const child = root.createScope({ tags: ['child'] });

    root.resolve('ILogger');
    child.resolve('ILogger');

    const rootApp = root.resolve(App);

    expect(rootApp.loggers.length).toBe(1);
  });

  it('should return injected instances by decorator', () => {
    const isLogger = (instance: unknown) => instance instanceof Logger;

    class App {
      constructor(@inject(select.instances(isLogger)) public loggers: Logger[]) {}
    }

    const container = new Container().addRegistration(R.fromClass(Logger));

    const logger0 = container.resolve('ILogger');
    const logger1 = container.resolve('ILogger');
    const app = container.resolve(App);

    expect(app.loggers).toHaveLength(2);
    expect(app.loggers[0]).toBe(logger0);
    expect(app.loggers[1]).toBe(logger1);
  });
});
import { bindTo, Container, inject, register, Registration as R, select } from '../../lib';

describe('Instances', function () {
  @register(bindTo('ILogger'))
  class Logger {}

  it('should return injected instances', () => {
    class App {
      constructor(@inject(select.instances()) public loggers: Logger[]) {}
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromClass(Logger));
    const child = root.createScope({ tags: ['child'] });

    root.resolve('ILogger');
    child.resolve('ILogger');

    const rootApp = root.resolve(App);
    const childApp = child.resolve(App);

    expect(childApp.loggers.length).toBe(1);
    expect(rootApp.loggers.length).toBe(2);
  });

  it('should return only current scope instances', () => {
    class App {
      constructor(@inject(select.instances().cascade(false)) public loggers: Logger[]) {}
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromClass(Logger));
    const child = root.createScope({ tags: ['child'] });

    root.resolve('ILogger');
    child.resolve('ILogger');

    const rootApp = root.resolve(App);

    expect(rootApp.loggers.length).toBe(1);
  });

  it('should return injected instances by decorator', () => {
    const isLogger = (instance: unknown) => instance instanceof Logger;

    class App {
      constructor(@inject(select.instances(isLogger)) public loggers: Logger[]) {}
    }

    const container = new Container().addRegistration(R.fromClass(Logger));

    const logger0 = container.resolve('ILogger');
    const logger1 = container.resolve('ILogger');
    const app = container.resolve(App);

    expect(app.loggers).toHaveLength(2);
    expect(app.loggers[0]).toBe(logger0);
    expect(app.loggers[1]).toBe(logger1);
  });
});

Filtering Instances

TypeScript __tests__/readme/filteringInstances.spec.ts
import { bindTo, Container, inject, register, Registration as R, select } from '../../lib';

@register(bindTo('ILogger'))
class Logger {}

class Service {}

const isLogger = (instance: unknown) => instance instanceof Logger;

class App {
  constructor(@inject(select.instances(isLogger)) public loggers: Logger[]) {}
}

describe('Filtering Instances', function () {
  it('should filter instances by predicate', function () {
    const container = new Container()
      .addRegistration(R.fromClass(Logger))
      .addRegistration(R.fromClass(Service).bindToKey('IService'));

    // Create multiple instances
    const logger1 = container.resolve('ILogger');
    const logger2 = container.resolve('ILogger');
    const service = container.resolve('IService');

    const app = container.resolve(App);

    // Should only include Logger instances, not Service
    expect(app.loggers).toHaveLength(2);
    expect(app.loggers[0]).toBe(logger1);
    expect(app.loggers[1]).toBe(logger2);
    expect(app.loggers.every((l) => l instanceof Logger)).toBe(true);
    expect(app.loggers.some((l) => l instanceof Service)).toBe(false);
  });

  it('should return empty array when no instances match predicate', function () {
    const container = new Container().addRegistration(R.fromClass(Service).bindToKey('IService'));

    container.resolve('IService');

    const app = container.resolve(App);

    expect(app.loggers).toHaveLength(0);
  });
});
import { bindTo, Container, inject, register, Registration as R, select } from '../../lib';

@register(bindTo('ILogger'))
class Logger {}

class Service {}

const isLogger = (instance: unknown) => instance instanceof Logger;

class App {
  constructor(@inject(select.instances(isLogger)) public loggers: Logger[]) {}
}

describe('Filtering Instances', function () {
  it('should filter instances by predicate', function () {
    const container = new Container()
      .addRegistration(R.fromClass(Logger))
      .addRegistration(R.fromClass(Service).bindToKey('IService'));

    // Create multiple instances
    const logger1 = container.resolve('ILogger');
    const logger2 = container.resolve('ILogger');
    const service = container.resolve('IService');

    const app = container.resolve(App);

    // Should only include Logger instances, not Service
    expect(app.loggers).toHaveLength(2);
    expect(app.loggers[0]).toBe(logger1);
    expect(app.loggers[1]).toBe(logger2);
    expect(app.loggers.every((l) => l instanceof Logger)).toBe(true);
    expect(app.loggers.some((l) => l instanceof Service)).toBe(false);
  });

  it('should return empty array when no instances match predicate', function () {
    const container = new Container().addRegistration(R.fromClass(Service).bindToKey('IService'));

    container.resolve('IService');

    const app = container.resolve(App);

    expect(app.loggers).toHaveLength(0);
  });
});

Disposal

Proper resource cleanup is essential for preventing memory leaks. The container provides a disposal mechanism that cleans up all resources and prevents further usage.

TypeScript __tests__/readme/disposing.spec.ts
import { Container, ContainerDisposedError, Registration as R, select } from '../../lib';

class Logger {}

describe('Disposing', function () {
  it('should container and make it unavailable for the further usage', function () {
    const root = new Container({ tags: ['root'] }).addRegistration(R.fromClass(Logger).bindToKey('ILogger'));

    root.dispose();

    expect(() => root.resolve('ILogger')).toThrow(ContainerDisposedError);
    expect(select.instances().resolve(root).length).toBe(0);
  });
});
import { Container, ContainerDisposedError, Registration as R, select } from '../../lib';

class Logger {}

describe('Disposing', function () {
  it('should container and make it unavailable for the further usage', function () {
    const root = new Container({ tags: ['root'] }).addRegistration(R.fromClass(Logger).bindToKey('ILogger'));

    root.dispose();

    expect(() => root.resolve('ILogger')).toThrow(ContainerDisposedError);
    expect(select.instances().resolve(root).length).toBe(0);
  });
});

Disposal Behavior

  • Disposes the container only. Not children
  • Unregisters all providers
  • Clears all cached instances
  • Throws ContainerDisposedError on any subsequent operations

Lazy Loading

Lazy loading defers the instantiation of dependencies until they're actually accessed. This can improve startup performance and enable circular dependency resolution.

TypeScript __tests__/readme/lazy.spec.ts
import { Container, inject, register, Registration as R, select as s, singleton } from '../../lib';

describe('lazy provider', () => {
  @register(singleton())
  class Flag {
    isSet = false;

    set() {
      this.isSet = true;
    }
  }

  class Service {
    name = 'Service';

    constructor(@inject('Flag') private flag: Flag) {
      this.flag.set();
    }

    greet() {
      return 'Hello';
    }
  }

  class App {
    constructor(@inject(s.token('Service').lazy()) public service: Service) {}

    run() {
      return this.service.greet();
    }
  }

  function createContainer() {
    const container = new Container();
    container.addRegistration(R.fromClass(Flag)).addRegistration(R.fromClass(Service));
    return container;
  }

  it('should not create an instance until method is not invoked', () => {
    // Arrange
    const container = createContainer();

    // Act
    container.resolve(App);
    const flag = container.resolve<Flag>('Flag');

    // Assert
    expect(flag.isSet).toBe(false);
  });

  it('should create an instance only when some method/property is invoked', () => {
    // Arrange
    const container = createContainer();

    // Act
    const app = container.resolve(App);
    const flag = container.resolve<Flag>('Flag');

    // Assert
    expect(app.run()).toBe('Hello');
    expect(flag.isSet).toBe(true);
  });

  it('should not create instance on every method invoked', () => {
    // Arrange
    const container = createContainer();

    // Act
    const app = container.resolve(App);

    // Assert
    expect(app.run()).toBe('Hello');
    expect(app.run()).toBe('Hello');
    expect(Array.from(container.getInstances()).filter((x) => x instanceof Service).length).toBe(1);
  });

  it('should create instance when property is invoked', () => {
    // Arrange
    const container = createContainer();

    // Act
    const app = container.resolve(App);
    const flag = container.resolve<Flag>('Flag');

    // Assert
    expect(app.service.name).toBe('Service');
    expect(flag.isSet).toBe(true);
  });
});
import { Container, inject, register, Registration as R, select as s, singleton } from '../../lib';

describe('lazy provider', () => {
  @register(singleton())
  class Flag {
    isSet = false;

    set() {
      this.isSet = true;
    }
  }

  class Service {
    name = 'Service';

    constructor(@inject('Flag') private flag: Flag) {
      this.flag.set();
    }

    greet() {
      return 'Hello';
    }
  }

  class App {
    constructor(@inject(s.token('Service').lazy()) public service: Service) {}

    run() {
      return this.service.greet();
    }
  }

  function createContainer() {
    const container = new Container();
    container.addRegistration(R.fromClass(Flag)).addRegistration(R.fromClass(Service));
    return container;
  }

  it('should not create an instance until method is not invoked', () => {
    // Arrange
    const container = createContainer();

    // Act
    container.resolve(App);
    const flag = container.resolve<Flag>('Flag');

    // Assert
    expect(flag.isSet).toBe(false);
  });

  it('should create an instance only when some method/property is invoked', () => {
    // Arrange
    const container = createContainer();

    // Act
    const app = container.resolve(App);
    const flag = container.resolve<Flag>('Flag');

    // Assert
    expect(app.run()).toBe('Hello');
    expect(flag.isSet).toBe(true);
  });

  it('should not create instance on every method invoked', () => {
    // Arrange
    const container = createContainer();

    // Act
    const app = container.resolve(App);

    // Assert
    expect(app.run()).toBe('Hello');
    expect(app.run()).toBe('Hello');
    expect(Array.from(container.getInstances()).filter((x) => x instanceof Service).length).toBe(1);
  });

  it('should create instance when property is invoked', () => {
    // Arrange
    const container = createContainer();

    // Act
    const app = container.resolve(App);
    const flag = container.resolve<Flag>('Flag');

    // Assert
    expect(app.service.name).toBe('Service');
    expect(flag.isSet).toBe(true);
  });
});

Memory Management

The container provides explicit disposal to prevent memory leaks:

  • dispose() clears all providers and instances
  • Child scopes are automatically disposed when parent is disposed
  • Instance tracking allows manual cleanup if needed
  • Hooks can be used to release external resources during disposal

Container Modules

Container modules encapsulate registration logic, making it easy to organize and compose your dependency configuration. This is particularly useful for environment-specific setups or feature-based organization.

Creating a Module

Modules implement the IContainerModule interface and use the applyTo method to register dependencies:

TypeScript __tests__/readme/containerModule.spec.ts
import { bindTo, Container, type IContainer, type IContainerModule, register, Registration as R } from '../../lib';

@register(bindTo('ILogger'))
class Logger {}

@register(bindTo('ILogger'))
class TestLogger {}

class Production implements IContainerModule {
  applyTo(container: IContainer): void {
    container.addRegistration(R.fromClass(Logger));
  }
}

class Development implements IContainerModule {
  applyTo(container: IContainer): void {
    container.addRegistration(R.fromClass(TestLogger));
  }
}

describe('Container Modules', function () {
  function createContainer(isProduction: boolean) {
    return new Container().useModule(isProduction ? new Production() : new Development());
  }

  it('should register production dependencies', function () {
    const container = createContainer(true);

    expect(container.resolve('ILogger')).toBeInstanceOf(Logger);
  });

  it('should register development dependencies', function () {
    const container = createContainer(false);

    expect(container.resolve('ILogger')).toBeInstanceOf(TestLogger);
  });
});
import { bindTo, Container, type IContainer, type IContainerModule, register, Registration as R } from '../../lib';

@register(bindTo('ILogger'))
class Logger {}

@register(bindTo('ILogger'))
class TestLogger {}

class Production implements IContainerModule {
  applyTo(container: IContainer): void {
    container.addRegistration(R.fromClass(Logger));
  }
}

class Development implements IContainerModule {
  applyTo(container: IContainer): void {
    container.addRegistration(R.fromClass(TestLogger));
  }
}

describe('Container Modules', function () {
  function createContainer(isProduction: boolean) {
    return new Container().useModule(isProduction ? new Production() : new Development());
  }

  it('should register production dependencies', function () {
    const container = createContainer(true);

    expect(container.resolve('ILogger')).toBeInstanceOf(Logger);
  });

  it('should register development dependencies', function () {
    const container = createContainer(false);

    expect(container.resolve('ILogger')).toBeInstanceOf(TestLogger);
  });
});

Use Cases

  • Environment configuration - Different implementations for dev/staging/production
  • Feature organization - Group related registrations together
  • Testing - Easy to swap modules for test scenarios
  • Modular architecture - Compose containers from smaller, reusable modules

Key Design Decisions

1. Separation of Concerns

The architecture separates three distinct responsibilities:

  • Container: Manages lifecycle and coordinates resolution
  • Provider: Handles instance creation
  • Injector: Handles dependency injection

This separation makes each component testable and replaceable independently.

2. Scope-Based Isolation

Scopes provide isolation while maintaining a parent-child relationship. This design enables:

  • Request-level isolation in web applications
  • Feature-level separation
  • Hierarchical dependency resolution
  • Per-scope singleton instances

3. Tag-Based Scoping

Tags provide a flexible way to control provider availability without hardcoding scope relationships. This:

  • Enables environment-specific configurations
  • Supports feature flags
  • Allows dynamic scope matching
  • Makes testing easier

4. Composite Pattern

The scope hierarchy uses the Composite pattern, where containers can contain other containers. This allows uniform treatment of individual containers and container hierarchies.

5. Null Object Pattern

The EmptyContainer class implements the Null Object pattern, providing a safe default parent for root containers. This eliminates null checks throughout the codebase.

Best Practices

  • Always dispose containers when they're no longer needed, especially in long-running applications
  • Use scopes for isolation - Create request-level scopes in web applications to prevent cross-request contamination
  • Tag your scopes - Use meaningful tags to control provider availability and make your code more maintainable
  • Query instances carefully - Be aware of cascading behavior when querying instances across scopes
  • Use lazy loading wisely - Lazy loading can help with performance, but be aware of when dependencies are actually created

API Reference

Container Methods

  • resolve<T>(key: DependencyKey | constructor<T>, options?: ResolveOptions): T - Resolve a dependency
  • createScope(options?: ScopeOptions): IContainer - Create a child scope
  • addRegistration(registration: IRegistration): IContainer - Register a provider
  • dispose(): void - Dispose the container and all resources
  • getInstances(): Iterable<Instance> - Get all instances created by this container
  • hasTag(tag: Tag): boolean - Check if container has a specific tag