DEV Community

Omri Luz
Omri Luz

Posted on

Building a Custom Dependency Injection Container in JavaScript

Building a Custom Dependency Injection Container in JavaScript

Dependency Injection (DI) is a design pattern widely embraced in software development, allowing for better management of dependencies and offering an enhancement to modularity and testability in applications. In this article, we will explore the underpinnings of creating a custom Dependency Injection container in JavaScript, addressing its historical context, advanced implementation techniques, potential pitfalls, and practical use cases.

Historical Context

Dependency Injection has its roots in the Inversion of Control (IoC) principle, a concept that gained popularity in the early 2000s. Traditionally, application components were tightly coupled; this tight coupling made unit testing challenging and hindered code maintainability. With the introduction of DI frameworks in languages like Java (e.g., Spring Framework) and C# (e.g., ASP.NET Core), developers began to acknowledge the importance of decoupling components and managing their lifecycles efficiently.

JavaScript, initially primarily a client-side language, evolved with the advent of Node.js and significant frameworks such as Angular, React, and Vue.js. As the ecosystem matured, the need for robust dependency management became critical. Custom DI containers began to emerge, allowing developers to specify how their components are instantiated, configured, and how their dependencies are resolved.

What is Dependency Injection?

In basic terms, Dependency Injection is a pattern wherein an object receives its dependencies from external sources rather than creating them internally. There are three primary forms of DI:

  1. Constructor Injection: Dependencies are provided through the constructor of a class.
  2. Setter Injection: Dependencies are set through setter methods after the object is constructed.
  3. Interface Injection: The injector defines an interface that the dependent class must implement.

We'll focus primarily on Constructor Injection as it is the most common and powerful usage in JavaScript applications.

Building a Custom DI Container

Initial Structure of a DI Container

We can start by defining a simple DI container that will allow registration of classes and resolution of their dependencies. Here's how the basic skeleton looks:

class DIContainer {
    constructor() {
        this.registry = new Map();
    }

    register(name, classConstructor) {
        this.registry.set(name, classConstructor);
    }

    resolve(name) {
        const ClassConstructor = this.registry.get(name);
        if (!ClassConstructor) throw new Error(`No provider found for "${name}"`);

        const dependencies = this.resolveDependencies(ClassConstructor);
        return new ClassConstructor(...dependencies);
    }

    resolveDependencies(ClassConstructor) {
        const paramTypes = Reflect.getMetadata("design:paramtypes", ClassConstructor) || [];
        return paramTypes.map(paramType => this.resolve(paramType.name));
    }
}
Enter fullscreen mode Exit fullscreen mode

Installing Reflect Metadata

To use Reflect.getMetadata, ensure that you have the reflect-metadata library installed:

npm install reflect-metadata
Enter fullscreen mode Exit fullscreen mode

Also, you should import it in your primary entry file:

import 'reflect-metadata';
Enter fullscreen mode Exit fullscreen mode

Advanced Example with Interfaces

Complex scenarios require more robust patterns. Suppose we have conditions based on interfaces. We will simulate an interface using abstract classes.

class ILogger {
    log(message) {
        throw new Error('Method not implemented.');
    }
}

class ConsoleLogger extends ILogger {
    log(message) {
        console.log(message);
    }
}

class UserService {
    constructor(logger) {
        this.logger = logger;
    }

    saveUser(user) {
        this.logger.log(`Saving user: ${JSON.stringify(user)}`);
        // Saving logic here...
    }
}

// Registering implementations
const container = new DIContainer();
container.register('ILogger', ConsoleLogger);
container.register('UserService', UserService);

// Resolving services
const userService = container.resolve('UserService');
userService.saveUser({ name: 'John Doe' });
Enter fullscreen mode Exit fullscreen mode

Handling Scoped and Transient Lifetimes

In real-world applications, you may need different lifetimes for services: singleton, scoped, or transient. Below is how to implement these lifetimes:

class DIContainer {
    constructor() {
        this.registry = new Map();
    }

    register(name, classConstructor, { lifetime = 'singleton' } = {}) {
        this.registry.set(name, { classConstructor, lifetime, instance: null });
    }

    resolve(name) {
        const { classConstructor, lifetime, instance } = this.registry.get(name);
        if (!classConstructor) throw new Error(`No provider found for "${name}"`);

        if (lifetime === 'singleton') {
            if (!instance) {
                this.registry.get(name).instance = new classConstructor(...this.resolveDependencies(classConstructor));
            }
            return this.registry.get(name).instance;
        }

        return new classConstructor(...this.resolveDependencies(classConstructor));
    }

    resolveDependencies(ClassConstructor) {
        const paramTypes = Reflect.getMetadata("design:paramtypes", ClassConstructor) || [];
        return paramTypes.map(paramType => this.resolve(paramType.name));
    }
}
Enter fullscreen mode Exit fullscreen mode

Edge Cases and Advanced Implementation Techniques

Circular Dependency Handling

Circular dependencies can present significant challenges in DI. Consider a scenario where A depends on B, and B also depends on A. Handling this requires a more sophisticated resolution logic:

class DIContainer {
    constructor() {
        this.registry = new Map();
        this.resolving = new Map(); // to track in-progress resolutions
    }

    register(name, classConstructor, options = {}) {
        this.registry.set(name, { classConstructor, options });
    }

    resolve(name) {
        if (this.resolving.has(name)) {
            throw new Error(`Circular dependency detected: "${name}" is already resolving.`);
        }

        const entry = this.registry.get(name);
        if (!entry) throw new Error(`No provider found for "${name}"`);

        this.resolving.set(name, true); // mark as resolving
        const dependencies = this.resolveDependencies(entry.classConstructor);
        const instance = new entry.classConstructor(...dependencies);
        this.resolving.delete(name); // finish resolving

        return instance;
    }

    resolveDependencies(ClassConstructor) {
        const paramTypes = Reflect.getMetadata("design:paramtypes", ClassConstructor) || [];
        return paramTypes.map(paramType => this.resolve(paramType.name));
    }
}
Enter fullscreen mode Exit fullscreen mode

Comparing Alternative Approaches

  1. Framework-Based Solutions: Established frameworks like Angular or inversify.js offer built-in DI capabilities, often reducing the burden for developers by handling complex DI scenarios automatically. However, they can also introduce performance overhead and complexity, which a custom solution could avoid.
  2. Service Locators: Instead of injecting dependencies, a service locator pattern allows classes to fetch their dependencies. This can reduce coupling but often leads to global state and hard-to-track dependencies, undermining the modularity that DI aims to achieve.

Real-World Usage Scenarios

  1. Microservices: In microservices, DI can help manage service dependencies, facilitating the replacement of mock dependencies during unit tests.
  2. API Gateways: Often use DI for authorizers, logging services, and routing dependencies to ensure modularity and testability.
  3. Web Frameworks: Custom DI containers find extensive usage in applications developed using frameworks for validation, middleware management, and custom filters.

Performance Considerations and Optimization Strategies

  1. Caching Resolved Instances: Storing resolved instances for singletons improves performance for repeated access.
  2. Lazy Loading: Delaying the creation of instances until required can minimize the upfront load, especially for heavyweight objects.
  3. Profiling and Benchmarking: Measuring the time spent on dependency resolution can help identify bottlenecks; using tools such as performance.now() in Node.js can provide insights.

Debugging and Pitfalls

  1. Error Messages: Implement comprehensive error handling and logs for failed resolutions and circular dependencies.
  2. Type Safety: Enforce types using TypeScript for improved robustness and early feedback during the development process.
  3. Documentation: Maintain clear documentation for registered services and their lifetimes to avoid confusion among team members.

Conclusion

Constructing a custom Dependency Injection container in JavaScript presents an excellent opportunity for developers to hone their software design skills. With a solid understanding of DI principles and techniques, one can craft a highly flexible and testable codebase, ultimately leading to more maintainable applications.

Further Reading and Resources

By thoughtfully implementing a DI container, you elevate your programming practice to a robust level, ensuring your applications are well-structured, modular, and testable, all while staying future-proof against evolving requirements. Happy coding!

Top comments (0)