A professional-grade dependency injection container for TypeScript applications built with SOLID principles.
This is not a toy container for simple dependency wiring. @webiny/di is engineered for architects and senior developers who build complex, maintainable systems using Clean Architecture, Domain-Driven Design, and rigorous adherence to SOLID principles.
⚠️ Prerequisites: This library assumes you understand and practice:
- Dependency Inversion Principle - Programming to abstractions, not implementations
- Open/Closed Principle - Extending behavior through decoration, not modification
- Interface Segregation - Focused, cohesive abstractions
If your codebase doesn't use abstractions, or you're simply using a Service Locator pattern, a simpler solution may be more appropriate.
- Features
- Who Should Use This
- Installation
- Quick Start
- Core Concepts
- Lifetime Scopes
- Why @webiny/di?
- Comparison with Other Containers
- Advanced Features
- Type Safety
- Real-World Example
- Best Practices
- Error Handling
- Testing
- Philosophy
- Contributing
- ✨ True Type Safety: Unlike other DI containers, type safety is enforced at compile-time. The
Abstraction<T>class unifies tokens and types, making it impossible to resolve dependencies with incorrect types. No manual generic passing, no runtime surprises. - 🎯 First-Class Decorator Pattern: The only DI container with true abstraction decoration. Implement the Open/Closed Principle without compromise - extend behavior through decoration, not modification.
- 🏛️ SOLID by Design: Every feature exists to enable rigorous adherence to SOLID principles. This isn't a general-purpose container - it's a tool for professional architects building maintainable systems.
- 🔗 Composite Pattern: Register multiple implementations as composites for elegant handling of collections
- 🏗️ Hierarchical Containers: Create child containers with inheritance for scoped lifetimes and feature isolation
- ⚡ Lifetime Scopes: Transient and Singleton lifetime management
- 🔍 Metadata-Based: Uses reflect-metadata for clean, decorator-free API
- 🎨 Clean Architecture Ready: Designed from the ground up for Clean Architecture and Domain-Driven Design
npm install @webiny/di reflect-metadataNote: This package requires
reflect-metadatato be installed and imported at your application's entry point.
@webiny/di is for you if:
- ✅ You build applications using Clean Architecture or Hexagonal Architecture
- ✅ You practice Domain-Driven Design
- ✅ You understand and apply SOLID principles rigorously
- ✅ You need to extend system behavior through composition, not modification
- ✅ You value compile-time safety over runtime flexibility
- ✅ You're building enterprise applications where maintainability is critical
This library is NOT for you if:
- ❌ You're looking for a quick way to wire up dependencies in a simple app
- ❌ Your codebase uses concrete classes everywhere (no abstractions)
- ❌ You prefer runtime configuration over compile-time safety
- ❌ You're building a prototype or MVP where architecture doesn't matter yet
import "reflect-metadata";
import { Container, Abstraction, createImplementation } from "@webiny/di";
// 1. Define an abstraction (interface token)
interface IUserRepository {
getById(id: string): Promise<User>;
}
const UserRepository = new Abstraction<IUserRepository>("UserRepository");
// 2. Create an implementation
class UserRepositoryImpl implements IUserRepository {
async getById(id: string): Promise<User> {
// implementation
}
}
const UserRepositoryImplementation = createImplementation({
abstraction: UserRepository,
implementation: UserRepositoryImpl,
dependencies: []
});
// 3. Register and resolve
const container = new Container();
container.register(UserRepositoryImplementation);
const userRepo = container.resolve(UserRepository);Abstractions are type-safe tokens that represent interfaces or abstract contracts.
interface ILogger {
log(message: string): void;
}
const Logger = new Abstraction<ILogger>("Logger");Use createImplementation to bind implementations to abstractions with their dependencies.
class ConsoleLogger implements ILogger {
log(message: string): void {
console.log(message);
}
}
const ConsoleLoggerImpl = createImplementation({
abstraction: Logger,
implementation: ConsoleLogger,
dependencies: []
});Specify dependencies as an array of abstractions or tuples with options.
interface IUserService {
createUser(name: string): Promise<void>;
}
const UserService = new Abstraction<IUserService>("UserService");
class UserServiceImpl implements IUserService {
constructor(
private repository: IUserRepository,
private logger: ILogger
) {}
async createUser(name: string): Promise<void> {
this.logger.log(`Creating user: ${name}`);
// implementation
}
}
const UserServiceImpl = createImplementation({
abstraction: UserService,
implementation: UserServiceImpl,
dependencies: [UserRepository, Logger]
});A new instance is created every time the dependency is resolved.
container.register(UserRepositoryImpl);
// or explicitly:
container.register(UserRepositoryImpl); // default is transientA single instance is created and reused for all resolutions.
container.register(UserRepositoryImpl).inSingletonScope();Decorators wrap existing implementations to add cross-cutting concerns without modifying the original code.
interface IPageRepository {
getById(id: string): Promise<Page>;
}
const PageRepository = new Abstraction<IPageRepository>("PageRepository");
// Base implementation
class PageRepositoryImpl implements IPageRepository {
async getById(id: string): Promise<Page> {
// fetch from API
}
}
// Caching decorator
class CachedPageRepository implements IPageRepository {
constructor(
private cache: ICache,
private decoratee: IPageRepository // The decorated instance
) {}
async getById(id: string): Promise<Page> {
const cached = await this.cache.get(id);
if (cached) return cached;
const page = await this.decoratee.getById(id);
await this.cache.set(id, page);
return page;
}
}
// Register base implementation
const PageRepositoryImplementation = createImplementation({
abstraction: PageRepository,
implementation: PageRepositoryImpl,
dependencies: []
});
container.register(PageRepositoryImplementation);
// Register decorator
const CachedPageRepositoryDecorator = createDecorator({
abstraction: PageRepository,
decorator: CachedPageRepository,
dependencies: [Cache] // Cache is injected, decoratee is passed automatically
});
container.registerDecorator(CachedPageRepositoryDecorator);Key Points:
- Decorators are applied in the order they are registered
- The last constructor parameter of a decorator must be the decorated type
- Dependencies are resolved before the decoratee is passed
Chain multiple decorators to compose functionality:
// 1. Base implementation
container.register(CreatePageUseCaseImpl);
// 2. Add validation
container.registerDecorator(ValidationDecorator);
// 3. Add authorization
container.registerDecorator(AuthorizationDecorator);
// 4. Add metrics
container.registerDecorator(MetricsDecorator);
// Resolution order: MetricsDecorator -> AuthorizationDecorator -> ValidationDecorator -> CreatePageUseCaseImplComposites collect multiple implementations and expose them as a single implementation.
interface IPlugin {
execute(): void;
}
const Plugin = new Abstraction<IPlugin>("Plugin");
class PluginComposite implements IPlugin {
constructor(private plugins: IPlugin[]) {}
execute(): void {
this.plugins.forEach(plugin => plugin.execute());
}
}
const PluginCompositeImpl = createComposite({
abstraction: Plugin,
implementation: PluginComposite,
dependencies: [[Plugin, { multiple: true }]]
});
container.registerComposite(PluginCompositeImpl);Resolve all implementations of an abstraction as an array:
interface IEventHandler {
handle(event: DomainEvent): Promise<void>;
}
const EventHandler = new Abstraction<IEventHandler>("EventHandler");
class EventPublisher {
constructor(private handlers: IEventHandler[]) {}
async publish(event: DomainEvent): Promise<void> {
for (const handler of this.handlers) {
await handler.handle(event);
}
}
}
const EventPublisherImpl = createImplementation({
abstraction: EventPublisher,
implementation: EventPublisher,
dependencies: [[EventHandler, { multiple: true }]]
});Mark dependencies as optional if they may not be registered:
class UserService {
constructor(
private repository: IUserRepository,
private logger?: ILogger // Optional
) {}
async createUser(name: string): Promise<void> {
this.logger?.log(`Creating user: ${name}`);
// implementation
}
}
const UserServiceImpl = createImplementation({
abstraction: UserService,
implementation: UserService,
dependencies: [UserRepository, [Logger, { optional: true }]]
});Create child containers that inherit registrations from their parent:
const parentContainer = new Container();
parentContainer.register(LoggerImpl).inSingletonScope();
const childContainer = parentContainer.createChildContainer();
// Child can resolve parent's registrations
const logger = childContainer.resolve(Logger);
// Child registrations don't affect parent
childContainer.register(UserRepositoryImpl);Use cases:
- Scoped lifetimes (e.g., per-request in web applications)
- Feature isolation
- Testing with overridden dependencies
Register pre-created instances directly:
const configInstance = new Configuration({ apiKey: "secret" });
container.registerInstance(Configuration, configInstance);Register a factory function for custom instance creation:
container.registerFactory(Logger, () => {
return new ConsoleLogger(process.env.LOG_LEVEL);
});Get all registered implementations of an abstraction:
// Register multiple implementations
container.register(EmailNotificationHandler);
container.register(SmsNotificationHandler);
container.register(PushNotificationHandler);
// Resolve all
const handlers = container.resolveAll(NotificationHandler);
// Returns: [EmailNotificationHandler, SmsNotificationHandler, PushNotificationHandler]Manually create instances with resolved dependencies:
const instance = container.resolveWithDependencies({
implementation: MyClass,
dependencies: [Logger, UserRepository]
});The container provides enforced type safety through the Abstraction<T> class. Unlike other DI containers where tokens and types are separate, here they are unified:
// The abstraction IS the token AND carries the type
const Logger = new Abstraction<ILogger>("Logger");
// ✅ Type is automatically inferred from the abstraction
const logger = container.resolve(Logger); // Type: ILogger (no manual generic needed!)
// ✅ Dependencies are type-checked against constructor parameters
const UserServiceImpl = createImplementation({
abstraction: UserService,
implementation: UserServiceImpl,
dependencies: [
UserRepository, // Type-checked: must be IUserRepository
Logger // Type-checked: must be ILogger
]
});
// ❌ TypeScript error: wrong type
const wrong: ISomeOtherType = container.resolve(Logger);
// Error: Type 'ILogger' is not assignable to type 'ISomeOtherType'This approach eliminates entire classes of bugs that exist in other DI containers where you can accidentally resolve the wrong type.
Here's a complete example of a feature using clean architecture principles:
import "reflect-metadata";
import { Container, Abstraction, createImplementation, createDecorator } from "@webiny/di";
// Domain Layer
interface User {
id: string;
name: string;
email: string;
}
// Application Layer - Repository Interface
interface IUserRepository {
getById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
const UserRepository = new Abstraction<IUserRepository>("UserRepository");
// Application Layer - Use Case
interface IGetUserUseCase {
execute(id: string): Promise<User | null>;
}
const GetUserUseCase = new Abstraction<IGetUserUseCase>("GetUserUseCase");
class GetUserUseCaseImpl implements IGetUserUseCase {
constructor(private repository: IUserRepository) {}
async execute(id: string): Promise<User | null> {
return this.repository.getById(id);
}
}
// Infrastructure Layer - Repository Implementation
interface IUserGateway {
fetchUser(id: string): Promise<User | null>;
}
const UserGateway = new Abstraction<IUserGateway>("UserGateway");
class UserRepositoryImpl implements IUserRepository {
constructor(private gateway: IUserGateway) {}
async getById(id: string): Promise<User | null> {
return this.gateway.fetchUser(id);
}
async save(user: User): Promise<void> {
// implementation
}
}
class UserGraphQLGateway implements IUserGateway {
async fetchUser(id: string): Promise<User | null> {
// GraphQL query
return { id, name: "John", email: "john@example.com" };
}
}
// Cross-cutting Concerns - Logging Decorator
interface ILogger {
log(message: string): void;
}
const Logger = new Abstraction<ILogger>("Logger");
class ConsoleLogger implements ILogger {
log(message: string): void {
console.log(`[LOG] ${message}`);
}
}
class LoggingGetUserDecorator implements IGetUserUseCase {
constructor(
private logger: ILogger,
private decoratee: IGetUserUseCase
) {}
async execute(id: string): Promise<User | null> {
this.logger.log(`Fetching user: ${id}`);
const user = await this.decoratee.execute(id);
this.logger.log(`User fetched: ${user?.name || "not found"}`);
return user;
}
}
// DI Setup
const container = new Container();
// Register infrastructure
container.register(
createImplementation({
abstraction: UserGateway,
implementation: UserGraphQLGateway,
dependencies: []
})
);
container
.register(
createImplementation({
abstraction: UserRepository,
implementation: UserRepositoryImpl,
dependencies: [UserGateway]
})
)
.inSingletonScope();
// Register use case
container.register(
createImplementation({
abstraction: GetUserUseCase,
implementation: GetUserUseCaseImpl,
dependencies: [UserRepository]
})
);
// Register logger
container
.register(
createImplementation({
abstraction: Logger,
implementation: ConsoleLogger,
dependencies: []
})
)
.inSingletonScope();
// Add logging decorator
container.registerDecorator(
createDecorator({
abstraction: GetUserUseCase,
decorator: LoggingGetUserDecorator,
dependencies: [Logger]
})
);
// Usage
const useCase = container.resolve(GetUserUseCase);
const user = await useCase.execute("123");// features/users/abstractions.ts
export interface IUserRepository {
/* ... */
}
export const UserRepository = new Abstraction<IUserRepository>("UserRepository");
export interface IGetUserUseCase {
/* ... */
}
export const GetUserUseCase = new Abstraction<IGetUserUseCase>("GetUserUseCase");export const UserRepository = new Abstraction<IUserRepository>("UserRepository");
export namespace UserRepository {
export type Interface = IUserRepository;
}
// Usage
function example(repo: UserRepository.Interface) {
// ...
}// features/users/feature.ts
export function registerUserFeature(container: Container) {
container.register(UserGatewayImpl);
container.register(UserRepositoryImpl).inSingletonScope();
container.register(GetUserUseCaseImpl);
}// Stateful services should be singletons
container.register(CacheImpl).inSingletonScope();
container.register(DatabaseConnectionImpl).inSingletonScope();
// Stateless services can be transient
container.register(GetUserUseCaseImpl); // transient by default// ✅ Good: Constructor injection
class UserService {
constructor(private repository: IUserRepository) {}
}
// ❌ Avoid: Property injection or service locator pattern
class UserService {
repository?: IUserRepository;
setRepository(repo: IUserRepository) {
this.repository = repo;
}
}The container provides clear error messages:
// Circular dependency detection
try {
container.resolve(ServiceA);
} catch (error) {
// Error: Circular dependency detected for ServiceA
}
// Missing registration
try {
container.resolve(UnregisteredService);
} catch (error) {
// Error: No registration found for UnregisteredService
}
// Missing abstraction metadata
try {
container.register(ImplementationWithoutMetadata);
} catch (error) {
// Error: No abstraction metadata found for ImplementationWithoutMetadata
}import { describe, it, expect, vi } from "vitest";
describe("GetUserUseCase", () => {
it("should fetch user by id", async () => {
const mockRepository: IUserRepository = {
getById: vi.fn().mockResolvedValue({ id: "1", name: "John" }),
save: vi.fn()
};
const useCase = new GetUserUseCaseImpl(mockRepository);
const user = await useCase.execute("1");
expect(user?.name).toBe("John");
expect(mockRepository.getById).toHaveBeenCalledWith("1");
});
});import { describe, it, expect, beforeEach } from "vitest";
describe("User Feature Integration", () => {
let container: Container;
beforeEach(() => {
container = new Container();
registerUserFeature(container);
});
it("should wire up all dependencies correctly", () => {
const useCase = container.resolve(GetUserUseCase);
expect(useCase).toBeDefined();
});
it("should execute use case with real dependencies", async () => {
const useCase = container.resolve(GetUserUseCase);
const user = await useCase.execute("123");
expect(user).toBeDefined();
});
});describe("User Feature with Overrides", () => {
it("should use mock repository in tests", async () => {
const parentContainer = new Container();
registerUserFeature(parentContainer);
const testContainer = parentContainer.createChildContainer();
// Override repository with mock
const mockRepo = new MockUserRepository();
testContainer.registerInstance(UserRepository, mockRepo);
const useCase = testContainer.resolve(GetUserUseCase);
const user = await useCase.execute("123");
expect(mockRepo.getByIdCalled).toBe(true);
});
});- Singleton Scope: Use for expensive-to-create objects (database connections, caches)
- Transient Scope: Use for lightweight, stateless services
- Resolution Caching: Singleton instances are cached automatically
- Decorator Overhead: Minimal, but avoid excessive decorator chains (>10)
Most DI containers are designed to make dependency wiring convenient. @webiny/di goes further - it's engineered to make SOLID principles not just possible, but natural and frictionless.
The truth about DI containers: They provide architectural value only when used with SOLID principles. Without abstractions (Dependency Inversion Principle) and without the need to extend behavior (Open/Closed Principle), a DI container is just glorified object instantiation.
@webiny/di doesn't pretend otherwise. This library is for professionals building:
- Clean Architecture applications with clear layer boundaries
- Domain-Driven Design systems with rich domain models
- Extensible platforms where behavior is composed, not hardcoded
- Enterprise applications where maintainability matters more than quick hacks
Most DI containers claim to be "type-safe," but they rely on manual generic parameters that can be incorrect. In these libraries, the token (string/symbol) and the type (generic parameter) are separate, allowing for mismatches:
// ❌ Other libraries - token and type are separate
const TYPES = { UserRepository: Symbol.for("UserRepository") };
container.bind<IUserRepository>(TYPES.UserRepository).to(UserRepositoryImpl);
// ❌ You can resolve with the WRONG type - compiles fine, fails at runtime!
const repo = container.get<IProductRepository>(TYPES.UserRepository);
// TypeScript can't catch this mistake because token and type are disconnected@webiny/di solves this by unifying tokens and types in the Abstraction<T> class:
// ✅ Token and type are unified - impossible to mess up
const UserRepository = new Abstraction<IUserRepository>("UserRepository");
// ✅ Type is automatically enforced
const repo = container.resolve(UserRepository); // Always returns IUserRepository
// ✅ Wrong type assignment caught at compile-time
const wrong: IProductRepository = container.resolve(UserRepository);
// TypeScript error: Type 'IUserRepository' is not assignable to type 'IProductRepository'Key benefits:
- No manual generic passing - types are automatic
- Compile-time verification of all dependencies
- Impossible to resolve with incorrect types
- Refactoring-safe - rename interfaces and all usages update
@webiny/di is the only DI container that implements true decoration of abstractions, making the Open/Closed Principle practical and natural.
"Software entities should be open for extension, but closed for modification." - Bertrand Meyer
This library makes it the default way of working. Decorators are registered on the abstraction, not on concrete implementations:
// ✅ Register base implementation - closed for modification
container.register(PageRepositoryImpl);
// ✅ Extend through decoration - open for extension
container.registerDecorator(CachingDecorator);
container.registerDecorator(LoggingDecorator);
container.registerDecorator(MetricsDecorator);
// ✅ All decorators automatically applied in order
const repo = container.resolve(PageRepository);
// Returns: MetricsDecorator -> LoggingDecorator -> CachingDecorator -> PageRepositoryImplThis is the Open/Closed Principle in practice:
- Base implementation is closed for modification - it never changes
- Behavior is open for extension - add decorators without touching core code
- Decorators are registered separately from implementations
- Third-party code can extend behavior by simply registering decorators
// Core application - defines abstractions and base implementations
container.register(CreatePageUseCaseImpl);
// ✅ Team member adds validation - extends via decoration
container.registerDecorator(ValidationDecorator);
// ✅ Team member adds authorization - extends via decoration
container.registerDecorator(AuthorizationDecorator);
// ✅ DevOps adds metrics - extends via decoration
container.registerDecorator(MetricsDecorator);
// ✅ Customer adds custom business rules - extends via decoration
container.registerDecorator(CustomBusinessRulesDecorator);
// All decorators automatically compose!
const useCase = container.resolve(CreatePageUseCase);
// Execution flow:
// CustomBusinessRulesDecorator
// -> MetricsDecorator
// -> AuthorizationDecorator
// -> ValidationDecorator
// -> CreatePageUseCaseImpl
// Original CreatePageUseCaseImpl was NEVER modified - Open/Closed Principle achieved.This pattern is fundamental to:
- Clean Architecture - Use cases decorated with cross-cutting concerns
- Domain-Driven Design - Domain services enhanced with infrastructure concerns
- Hexagonal Architecture - Ports decorated with adapters
- CQRS - Command handlers decorated with validation, authorization, auditing
Only @webiny/di makes this pattern first-class. In other containers, you're fighting the framework. Here, you're working with it.
@webiny/di isn't trying to be a general-purpose DI container. It's optimized for one thing: making SOLID principles practical in TypeScript applications.
Here's how it compares to other popular containers:
| Feature | @webiny/di | InversifyJS | TSyringe |
|---|---|---|---|
| Type Safety | ✅ Enforced (Token = Type) | ||
| Wrong Type Resolution | ✅ Compile error | ❌ Runtime error | ❌ Runtime error |
| Decorator Pattern | ✅ First-class (abstraction decoration) | ❌ Manual chaining only | ❌ Manual chaining only |
| Open/Closed Principle | ✅ Native support | ||
| Composites | ✅ Built-in | ❌ No | ❌ No |
| Child Containers | ✅ Yes | ✅ Yes | ✅ Yes |
| Metadata | ✅ reflect-metadata | ✅ reflect-metadata | ✅ reflect-metadata |
| Target Audience | Professional architects | General purpose | General purpose |
| Learning Curve | Low (if you know SOLID) | Medium | Low |
Bottom line: If you're building with SOLID principles, @webiny/di removes friction. If you're not, other containers might be more flexible for your use case.
@webiny/di is opinionated by design. It embodies a specific philosophy about software architecture:
What people call "plugins" are simply:
- Implementations of abstractions defined by the core system
- Decorations of abstractions to extend behavior
- Composites that collect multiple implementations
A DI container without SOLID principles is like:
- A type system without types
- A database without queries
- A compiler without syntax checking
You can use it, but you're missing the entire point.
This library doesn't try to be everything to everyone. It's a professional tool for developers who:
- Understand that architecture matters!
- Know that maintainability is more valuable than initial simplicity
- Accept that good design requires discipline and expertise
If you're building a throwaway prototype, use something simpler. If you're building a system that will live for years and be maintained by multiple teams, @webiny/di will pay dividends.
Some DI containers let you do anything at runtime. This library intentionally restricts you to compile-time verified operations.
Why? Because runtime errors in production are expensive. Compile-time errors are free.
We'd rather you hit a TypeScript error during development than a runtime error at 3 AM.
- 💬 Slack: Join our community
- 🐛 Issues: GitHub Issues