Provider
Providers are factories that create dependency instances. They can be configured with various features like singletons, argument binding, visibility control, aliases, and decorators. Understanding providers is key to mastering the IoC container.
Factory Pattern
Providers implement the Factory pattern, encapsulating the creation logic of dependencies. This allows for complex instantiation logic while keeping the container simple.
Provider Types
There are three main ways to create providers:
- Class Provider:
Provider.fromClass(Logger)- Creates instances from a class - Value Provider:
Provider.fromValue(value)- Returns a constant value - Factory Provider:
new Provider((container, ...args) => ...)- Custom factory function
Singleton
Singleton providers ensure only one instance is created per scope. This is perfect for services like loggers, database connections, or configuration managers.
import { bindTo, Container, register, Registration as R, singleton } from '../../lib';
@register(bindTo('logger'), singleton())
class Logger {}
describe('Singleton', function () {
function createContainer() {
return new Container();
}
it('should resolve the same container per every request', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
expect(container.resolve('logger')).toBe(container.resolve('logger'));
});
it('should resolve different dependency per scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(container.resolve('logger')).not.toBe(child.resolve('logger'));
});
it('should resolve the same dependency for scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(child.resolve('logger')).toBe(child.resolve('logger'));
});
});
import { bindTo, Container, register, Registration as R, singleton } from '../../lib';
@register(bindTo('logger'), singleton())
class Logger {}
describe('Singleton', function () {
function createContainer() {
return new Container();
}
it('should resolve the same container per every request', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
expect(container.resolve('logger')).toBe(container.resolve('logger'));
});
it('should resolve different dependency per scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(container.resolve('logger')).not.toBe(child.resolve('logger'));
});
it('should resolve the same dependency for scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(child.resolve('logger')).toBe(child.resolve('logger'));
});
});
Per-Scope Singletons
Each scope maintains its own singleton instance. This means different scopes will have different instances:
const container = new Container()
.addRegistration(R.fromClass(Logger));
const child = container.createScope();
// Different instances in different scopes
expect(container.resolve('logger')).not.toBe(child.resolve('logger'));
// Same instance within the same scope
expect(child.resolve('logger')).toBe(child.resolve('logger'));const container = new Container()
.addRegistration(R.fromClass(Logger));
const child = container.createScope();
// Different instances in different scopes
expect(container.resolve('logger')).not.toBe(child.resolve('logger'));
// Same instance within the same scope
expect(child.resolve('logger')).toBe(child.resolve('logger'));Use Cases
- Shared state within a scope
- Expensive object creation (database connections, HTTP clients)
- Configuration objects
- Service locators
Arguments
Argument providers allow you to bind constructor arguments at registration time. This is useful for configuration values, dependencies that should be resolved at provider creation, or default values.
import { bindTo, Container, register, Registration as R, singleton } from '../../lib';
@register(bindTo('logger'), singleton())
class Logger {}
describe('Singleton', function () {
function createContainer() {
return new Container();
}
it('should resolve the same container per every request', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
expect(container.resolve('logger')).toBe(container.resolve('logger'));
});
it('should resolve different dependency per scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(container.resolve('logger')).not.toBe(child.resolve('logger'));
});
it('should resolve the same dependency for scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(child.resolve('logger')).toBe(child.resolve('logger'));
});
});
import { bindTo, Container, register, Registration as R, singleton } from '../../lib';
@register(bindTo('logger'), singleton())
class Logger {}
describe('Singleton', function () {
function createContainer() {
return new Container();
}
it('should resolve the same container per every request', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
expect(container.resolve('logger')).toBe(container.resolve('logger'));
});
it('should resolve different dependency per scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(container.resolve('logger')).not.toBe(child.resolve('logger'));
});
it('should resolve the same dependency for scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(child.resolve('logger')).toBe(child.resolve('logger'));
});
});
Argument Priority
Provider arguments take precedence over arguments passed to resolve():
const container = new Container()
.addRegistration(R.fromClass(Logger).pipe(args('name')));
// Provider argument 'name' takes priority
const logger = container.resolve<Logger>('logger', { args: ['file'] });
expect(logger.name).toBe('name');
expect(logger.type).toBe('file'); // Second argument still passedconst container = new Container()
.addRegistration(R.fromClass(Logger).pipe(args('name')));
// Provider argument 'name' takes priority
const logger = container.resolve<Logger>('logger', { args: ['file'] });
expect(logger.name).toBe('name');
expect(logger.type).toBe('file'); // Second argument still passedVisibility
Visibility control allows you to restrict which scopes can access certain
dependencies using ScopeAccessRule. This is useful for implementing access control, feature
flags, or environment-specific services.
import {
bindTo,
Container,
DependencyNotFoundError,
register,
Registration as R,
scope,
scopeAccess,
singleton,
} from '../../lib';
describe('Visibility', function () {
it('should hide from children', () => {
@register(
bindTo('logger'),
scope((s) => s.hasTag('root')),
singleton(),
scopeAccess(({ invocationScope, providerScope }) => invocationScope === providerScope),
)
class FileLogger {}
const parent = new Container({ tags: ['root'] }).addRegistration(R.fromClass(FileLogger));
const child = parent.createScope({ tags: ['child'] });
expect(() => child.resolve('logger')).toThrowError(DependencyNotFoundError);
expect(parent.resolve('logger')).toBeInstanceOf(FileLogger);
});
});
import {
bindTo,
Container,
DependencyNotFoundError,
register,
Registration as R,
scope,
scopeAccess,
singleton,
} from '../../lib';
describe('Visibility', function () {
it('should hide from children', () => {
@register(
bindTo('logger'),
scope((s) => s.hasTag('root')),
singleton(),
scopeAccess(({ invocationScope, providerScope }) => invocationScope === providerScope),
)
class FileLogger {}
const parent = new Container({ tags: ['root'] }).addRegistration(R.fromClass(FileLogger));
const child = parent.createScope({ tags: ['child'] });
expect(() => child.resolve('logger')).toThrowError(DependencyNotFoundError);
expect(parent.resolve('logger')).toBeInstanceOf(FileLogger);
});
});
Use Cases
- Admin-only services
- Environment-specific dependencies (dev/staging/prod)
- Feature flags
- Security boundaries
Alias
Aliases allow you to group multiple registrations under a common identifier. This is perfect for plugin systems, middleware, or any scenario where you need to resolve multiple implementations of the same interface.
import {
bindTo,
Container,
DependencyNotFoundError,
inject,
register,
Registration as R,
scope,
select as s,
} from '../../lib';
describe('alias', () => {
const IMiddlewareKey = 'IMiddleware';
const middleware = register(bindTo(s.alias(IMiddlewareKey)));
interface IMiddleware {
applyTo(application: IApplication): void;
}
interface IApplication {
use(module: IMiddleware): void;
markMiddlewareAsApplied(name: string): void;
}
@middleware
class LoggerMiddleware implements IMiddleware {
applyTo(application: IApplication): void {
application.markMiddlewareAsApplied('LoggerMiddleware');
}
}
@middleware
class ErrorHandlerMiddleware implements IMiddleware {
applyTo(application: IApplication): void {
application.markMiddlewareAsApplied('ErrorHandlerMiddleware');
}
}
it('should resolve by some alias', () => {
class App implements IApplication {
private appliedMiddleware: Set<string> = new Set();
constructor(@inject(s.alias(IMiddlewareKey)) public middleware: IMiddleware[]) {}
markMiddlewareAsApplied(name: string): void {
this.appliedMiddleware.add(name);
}
isMiddlewareApplied(name: string): boolean {
return this.appliedMiddleware.has(name);
}
use(module: IMiddleware): void {
module.applyTo(this);
}
run() {
for (const module of this.middleware) {
module.applyTo(this);
}
}
}
const container = new Container()
.addRegistration(R.fromClass(LoggerMiddleware))
.addRegistration(R.fromClass(ErrorHandlerMiddleware));
const app = container.resolve(App);
app.run();
expect(app.isMiddlewareApplied('LoggerMiddleware')).toBe(true);
expect(app.isMiddlewareApplied('ErrorHandlerMiddleware')).toBe(true);
});
it('should resolve by some alias', () => {
@register(bindTo(s.alias('ILogger')))
class FileLogger {}
const container = new Container().addRegistration(R.fromClass(FileLogger));
expect(container.resolveOneByAlias('ILogger')).toBeInstanceOf(FileLogger);
expect(() => container.resolve('logger')).toThrowError(DependencyNotFoundError);
});
it('should resolve by alias', () => {
@register(bindTo(s.alias('ILogger')), scope((s) => s.hasTag('root')))
class FileLogger {}
@register(bindTo(s.alias('ILogger')), scope((s) => s.hasTag('child')))
class DbLogger {}
const container = new Container({ tags: ['root'] })
.addRegistration(R.fromClass(FileLogger))
.addRegistration(R.fromClass(DbLogger));
const result1 = container.resolveOneByAlias('ILogger');
const child = container.createScope({ tags: ['child'] });
const result2 = child.resolveOneByAlias('ILogger');
expect(result1).toBeInstanceOf(FileLogger);
expect(result2).toBeInstanceOf(DbLogger);
});
});
import {
bindTo,
Container,
DependencyNotFoundError,
inject,
register,
Registration as R,
scope,
select as s,
} from '../../lib';
describe('alias', () => {
const IMiddlewareKey = 'IMiddleware';
const middleware = register(bindTo(s.alias(IMiddlewareKey)));
interface IMiddleware {
applyTo(application: IApplication): void;
}
interface IApplication {
use(module: IMiddleware): void;
markMiddlewareAsApplied(name: string): void;
}
@middleware
class LoggerMiddleware implements IMiddleware {
applyTo(application: IApplication): void {
application.markMiddlewareAsApplied('LoggerMiddleware');
}
}
@middleware
class ErrorHandlerMiddleware implements IMiddleware {
applyTo(application: IApplication): void {
application.markMiddlewareAsApplied('ErrorHandlerMiddleware');
}
}
it('should resolve by some alias', () => {
class App implements IApplication {
private appliedMiddleware: Set<string> = new Set();
constructor(@inject(s.alias(IMiddlewareKey)) public middleware: IMiddleware[]) {}
markMiddlewareAsApplied(name: string): void {
this.appliedMiddleware.add(name);
}
isMiddlewareApplied(name: string): boolean {
return this.appliedMiddleware.has(name);
}
use(module: IMiddleware): void {
module.applyTo(this);
}
run() {
for (const module of this.middleware) {
module.applyTo(this);
}
}
}
const container = new Container()
.addRegistration(R.fromClass(LoggerMiddleware))
.addRegistration(R.fromClass(ErrorHandlerMiddleware));
const app = container.resolve(App);
app.run();
expect(app.isMiddlewareApplied('LoggerMiddleware')).toBe(true);
expect(app.isMiddlewareApplied('ErrorHandlerMiddleware')).toBe(true);
});
it('should resolve by some alias', () => {
@register(bindTo(s.alias('ILogger')))
class FileLogger {}
const container = new Container().addRegistration(R.fromClass(FileLogger));
expect(container.resolveOneByAlias('ILogger')).toBeInstanceOf(FileLogger);
expect(() => container.resolve('logger')).toThrowError(DependencyNotFoundError);
});
it('should resolve by alias', () => {
@register(bindTo(s.alias('ILogger')), scope((s) => s.hasTag('root')))
class FileLogger {}
@register(bindTo(s.alias('ILogger')), scope((s) => s.hasTag('child')))
class DbLogger {}
const container = new Container({ tags: ['root'] })
.addRegistration(R.fromClass(FileLogger))
.addRegistration(R.fromClass(DbLogger));
const result1 = container.resolveOneByAlias('ILogger');
const child = container.createScope({ tags: ['child'] });
const result2 = child.resolveOneByAlias('ILogger');
expect(result1).toBeInstanceOf(FileLogger);
expect(result2).toBeInstanceOf(DbLogger);
});
});
Use Cases
- Middleware registration
- Plugin systems
- Multiple implementations of an interface
- Event handlers or observers
Decorator
The decorator pattern allows you to wrap instances with additional functionality without modifying the original class. This is useful for cross-cutting concerns like logging, caching, or transaction management.
import {
bindTo,
Container,
decorate,
type IContainer,
inject,
register,
Registration as R,
select as s,
singleton,
} from '../../lib';
describe('lazy provider', () => {
@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;
}
class LogRepository implements IRepository {
constructor(
private repository: IRepository,
@inject(s.token('Logger').lazy()) private logger: Logger,
) {}
async save(item: Todo): Promise<void> {
this.logger.log(item.id);
return this.repository.save(item);
}
}
const logRepo = (dep: IRepository, scope: IContainer) => scope.resolve(LogRepository, { args: [dep] });
@register(bindTo('IRepository'), decorate(logRepo))
class TodoRepository implements IRepository {
async save(item: Todo): Promise<void> {}
}
class App {
constructor(@inject('IRepository') public repository: IRepository) {}
async run() {
await this.repository.save({ id: '1', text: 'Hello' });
await this.repository.save({ id: '2', text: 'Hello' });
}
}
function createContainer() {
const container = new Container();
container.addRegistration(R.fromClass(TodoRepository)).addRegistration(R.fromClass(Logger));
return container;
}
it('should decorate repo by logger middleware', async () => {
// Arrange
const container = createContainer();
// Act
const app = container.resolve(App);
const logger = container.resolve<Logger>('Logger');
await app.run();
// Assert
expect(logger.printLogs()).toBe('1,2');
});
});
import {
bindTo,
Container,
decorate,
type IContainer,
inject,
register,
Registration as R,
select as s,
singleton,
} from '../../lib';
describe('lazy provider', () => {
@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;
}
class LogRepository implements IRepository {
constructor(
private repository: IRepository,
@inject(s.token('Logger').lazy()) private logger: Logger,
) {}
async save(item: Todo): Promise<void> {
this.logger.log(item.id);
return this.repository.save(item);
}
}
const logRepo = (dep: IRepository, scope: IContainer) => scope.resolve(LogRepository, { args: [dep] });
@register(bindTo('IRepository'), decorate(logRepo))
class TodoRepository implements IRepository {
async save(item: Todo): Promise<void> {}
}
class App {
constructor(@inject('IRepository') public repository: IRepository) {}
async run() {
await this.repository.save({ id: '1', text: 'Hello' });
await this.repository.save({ id: '2', text: 'Hello' });
}
}
function createContainer() {
const container = new Container();
container.addRegistration(R.fromClass(TodoRepository)).addRegistration(R.fromClass(Logger));
return container;
}
it('should decorate repo by logger middleware', async () => {
// Arrange
const container = createContainer();
// Act
const app = container.resolve(App);
const logger = container.resolve<Logger>('Logger');
await app.run();
// Assert
expect(logger.printLogs()).toBe('1,2');
});
});
Use Cases
- Cross-cutting concerns (logging, caching, transactions)
- AOP (Aspect-Oriented Programming)
- Wrapping services with additional behavior
- Proxy patterns
Lazy Loading
Lazy providers defer instantiation until the dependency is actually accessed. This can improve startup performance and enable circular dependency resolution.
import { bindTo, Container, register, Registration as R, singleton } from '../../lib';
@register(bindTo('logger'), singleton())
class Logger {}
describe('Singleton', function () {
function createContainer() {
return new Container();
}
it('should resolve the same container per every request', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
expect(container.resolve('logger')).toBe(container.resolve('logger'));
});
it('should resolve different dependency per scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(container.resolve('logger')).not.toBe(child.resolve('logger'));
});
it('should resolve the same dependency for scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(child.resolve('logger')).toBe(child.resolve('logger'));
});
});
import { bindTo, Container, register, Registration as R, singleton } from '../../lib';
@register(bindTo('logger'), singleton())
class Logger {}
describe('Singleton', function () {
function createContainer() {
return new Container();
}
it('should resolve the same container per every request', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
expect(container.resolve('logger')).toBe(container.resolve('logger'));
});
it('should resolve different dependency per scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(container.resolve('logger')).not.toBe(child.resolve('logger'));
});
it('should resolve the same dependency for scope', function () {
const container = createContainer().addRegistration(R.fromClass(Logger));
const child = container.createScope();
expect(child.resolve('logger')).toBe(child.resolve('logger'));
});
});
Provider Pipeline
Providers can be composed using the pipeline pattern. Each transformation wraps the provider with additional functionality:
fromClass Logger"] Args["Args Provider
args '/config.json'"] Singleton["Singleton Provider
cache instance"] Visibility["Visibility Provider
scopeAccess rule"] Lazy["Lazy Provider
deferred creation"] Base -->|pipe args| Args Args -->|pipe singleton| Singleton Singleton -->|pipe scopeAccess| Visibility Visibility -->|pipe lazy| Lazy Lazy -->|final| Final["Final Provider"] style Base fill:#e1e4e8 style Args fill:#c6e48b style Singleton fill:#7bc96f style Visibility fill:#239a3b style Lazy fill:#196127 style Final fill:#0366d6,color:#fff
Provider Composition Example
const provider = Provider.fromClass(Logger)
.pipe(args('/config.json')) // 1. Bind arguments
.pipe(singleton()) // 2. Cache instances
.pipe(scopeAccess(rule)) // 3. Control visibility
.pipe(lazy()); // 4. Defer creation
// Each pipe wraps the previous provider
// Final provider: Lazy(Visibility(Singleton(Args(Base))))
const provider = Provider.fromClass(Logger)
.pipe(args('/config.json')) // 1. Bind arguments
.pipe(singleton()) // 2. Cache instances
.pipe(scopeAccess(rule)) // 3. Control visibility
.pipe(lazy()); // 4. Defer creation
// Each pipe wraps the previous provider
// Final provider: Lazy(Visibility(Singleton(Args(Base))))
Decorator Pattern
The provider pipeline uses the Decorator pattern, allowing features to be added incrementally without modifying the base provider. Each pipe operation wraps the provider with additional functionality.
Composable Providers
The pipeline pattern allows providers to be composed from smaller pieces. This design:
- Keeps the base provider simple
- Allows features to be added incrementally
- Makes the system extensible
- Follows the Open/Closed Principle
You can also use the pipe method in registrations:
const container = new Container()
.addRegistration(
R.fromClass(ConfigService)
.pipe(args('/default/config.json'))
.pipe(singleton())
.pipe(scopeAccess(({ invocationScope }) => invocationScope.hasTag('admin')))
);const container = new Container()
.addRegistration(
R.fromClass(ConfigService)
.pipe(args('/default/config.json'))
.pipe(singleton())
.pipe(scopeAccess(({ invocationScope }) => invocationScope.hasTag('admin')))
);Performance Considerations
Singleton Caching
Singleton providers cache instances per scope, avoiding unnecessary object
creation. The cache is implemented using a Map for O(1) lookup
performance.
Lazy Loading
Lazy providers use JavaScript proxies to defer instantiation until access. This improves startup time by avoiding eager initialization of unused dependencies.
Provider Lookup
Providers are stored in a Map keyed by DependencyKey, providing O(1) lookup performance. Alias resolution uses a separate AliasMap for efficient multi-key lookups.
Custom Providers
Implement IProvider or extend ProviderDecorator to
create custom providers:
class CustomProvider extends ProviderDecorator {
resolve(container: IContainer, options: ProviderOptions): T {
// Your custom resolution logic
}
}class CustomProvider extends ProviderDecorator {
resolve(container: IContainer, options: ProviderOptions): T {
// Your custom resolution logic
}
}Best Practices
- Use singletons for expensive resources - Database connections, HTTP clients, configuration objects
- Prefer argsFn for dynamic values - When arguments depend on other container values
- Use visibility for security - Restrict access to sensitive services
- Aliases for collections - Group related services together
- Decorators for cross-cutting concerns - Keep your core classes clean
- Lazy loading for performance - Defer expensive object creation