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
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.
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.
tags: 'root'"] Request1["Request Scope 1
tags: 'request', 'user:123'"] Request2["Request Scope 2
tags: 'request', 'user:456'"] Feature1["Feature Scope 1
tags: 'feature:admin'"] Feature2["Feature Scope 2
tags: 'feature:user'"] Root -->|createScope| Request1 Root -->|createScope| Request2 Request1 -->|createScope| Feature1 Request2 -->|createScope| Feature2 style Root fill:#0366d6,color:#fff style Request1 fill:#28a745,color:#fff style Request2 fill:#28a745,color:#fff style Feature1 fill:#ffc107,color:#000 style Feature2 fill:#ffc107,color:#000
Creating a Scope
When you create a new scope using createScope(), the container
follows a specific process to set up the child scope. The new scope maintains
a reference to its parent, allowing it to access parent registrations while
maintaining its own isolated instance cache.
with parent reference]) --> CopyHooks[Copy hooks
onConstruct, onDispose] CopyHooks --> GetRegs[Get all registrations
parent + current scope] GetRegs --> Apply[Apply registration to new scope] Apply --> AddScope[Add scope to
scopes list] AddScope --> Return([Return new scope]) 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:
- Check if provider exists in current scope
- Check if provider has access in current scope (scope access rule)
- If not found, recursively check parent scopes
- If found in parent, check access from current scope
- Throw
DependencyNotFoundErrorif not found anywhere
Dependency Resolution Flow
The following sequence diagram illustrates how a dependency is resolved from the container:
constructor?} CheckConstructor -->|Yes| Injector[Resolve constructor
from Injector] CheckConstructor -->|No| FindProvider[Find provider
by string or symbol] FindProvider --> Container{Provider exists
and has access
to current scope?} Container -->|Yes| Provider[Resolve dependency
from provider] Container -->|No| ResolveParent[Resolve dependency
from parent scope] style Start fill:#4CAF50,stroke:#2E7D32,stroke-width:3px,color:#fff style CheckConstructor fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#fff style Container fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#fff style Injector fill:#2196F3,stroke:#1565C0,stroke-width:2px,color:#fff style FindProvider fill:#9C27B0,stroke:#6A1B9A,stroke-width:2px,color:#fff style Provider fill:#00BCD4,stroke:#00838F,stroke-width:2px,color:#fff style ResolveParent fill:#FF5722,stroke:#BF360C,stroke-width:2px,color:#fff
Instance Management
The container tracks all instances it creates, allowing you to query and manage them. This is particularly useful for cleanup operations, debugging, or implementing custom lifecycle management.
Querying Instances
import { bindTo, Container, inject, register, Registration as R, select } from '../../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
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.
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
ContainerDisposedErroron any subsequent operations
Lazy Loading
Lazy loading defers the instantiation of dependencies until they're actually accessed. This can improve startup performance and enable circular dependency resolution.
import { 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:
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