Skip to content
/ di Public

A professional-grade dependency injection container for TypeScript applications built with SOLID principles.

License

Notifications You must be signed in to change notification settings

webiny/di

Repository files navigation

@webiny/di

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.

Table of Contents

Features

  • 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

Installation

npm install @webiny/di reflect-metadata

Note: This package requires reflect-metadata to be installed and imported at your application's entry point.

Who Should Use This

@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

Quick Start

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

Core Concepts

Abstractions

Abstractions are type-safe tokens that represent interfaces or abstract contracts.

interface ILogger {
  log(message: string): void;
}

const Logger = new Abstraction<ILogger>("Logger");

Implementations

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: []
});

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]
});

Lifetime Scopes

Transient (Default)

A new instance is created every time the dependency is resolved.

container.register(UserRepositoryImpl);
// or explicitly:
container.register(UserRepositoryImpl); // default is transient

Singleton

A single instance is created and reused for all resolutions.

container.register(UserRepositoryImpl).inSingletonScope();

Advanced Features

Decorator Pattern

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

Multiple Decorators

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 -> CreatePageUseCaseImpl

Composite Pattern

Composites 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);

Dependency Options

Multiple Dependencies

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 }]]
});

Optional Dependencies

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 }]]
});

Hierarchical Containers

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

Instance Registration

Register pre-created instances directly:

const configInstance = new Configuration({ apiKey: "secret" });
container.registerInstance(Configuration, configInstance);

Factory Registration

Register a factory function for custom instance creation:

container.registerFactory(Logger, () => {
  return new ConsoleLogger(process.env.LOG_LEVEL);
});

Resolve All

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]

Manual Resolution with Dependencies

Manually create instances with resolved dependencies:

const instance = container.resolveWithDependencies({
  implementation: MyClass,
  dependencies: [Logger, UserRepository]
});

Type Safety

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.

Real-World Example

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");

Best Practices

1. Define Abstractions at Module Boundaries

// features/users/abstractions.ts
export interface IUserRepository {
  /* ... */
}
export const UserRepository = new Abstraction<IUserRepository>("UserRepository");

export interface IGetUserUseCase {
  /* ... */
}
export const GetUserUseCase = new Abstraction<IGetUserUseCase>("GetUserUseCase");

2. Use Namespaces for Convenience

export const UserRepository = new Abstraction<IUserRepository>("UserRepository");

export namespace UserRepository {
  export type Interface = IUserRepository;
}

// Usage
function example(repo: UserRepository.Interface) {
  // ...
}

3. Register in Feature Modules

// features/users/feature.ts
export function registerUserFeature(container: Container) {
  container.register(UserGatewayImpl);
  container.register(UserRepositoryImpl).inSingletonScope();
  container.register(GetUserUseCaseImpl);
}

4. Use Singletons for Stateful Services

// Stateful services should be singletons
container.register(CacheImpl).inSingletonScope();
container.register(DatabaseConnectionImpl).inSingletonScope();

// Stateless services can be transient
container.register(GetUserUseCaseImpl); // transient by default

5. Prefer Constructor Injection

// ✅ 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;
  }
}

Error Handling

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
}

Testing

Unit Testing with Mocks

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");
  });
});

Integration Testing with Container

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();
  });
});

Testing with Child Containers

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);
  });
});

Performance Considerations

  • 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)

Why @webiny/di?

Built for SOLID Principles, Not Just Convenience

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

True Type Safety

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

First-Class Decorator Pattern

@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 -> PageRepositoryImpl

This 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

Real-World Example: Extensible Systems

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

Comparison with Other Containers

@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) ⚠️ Manual (Token ≠ Type) ⚠️ Manual (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 ⚠️ Manual implementation ⚠️ Manual implementation
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.

Philosophy

@webiny/di is opinionated by design. It embodies a specific philosophy about software architecture:

There Are No "Plugins"

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

DI Containers Are Only Truly Useful with SOLID

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.

Professional Tools for Professional Developers

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.

We Choose Compile-Time Safety Over Runtime Flexibility

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.

Contributing

About

A professional-grade dependency injection container for TypeScript applications built with SOLID principles.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •