29th May 2024

Implementing the Dependency Inversion Principle in TypeScript: A Practical Guide

Dependency-Inversion-Blog-VS-Online-img

When developing software, developers often decompose large, complex problems into smaller, more manageable subproblems. These subproblems are solved through the implementation of individual components or modules. By combining these components, a complete system is created to address the original problem.

The quality of the systems and applications we build is significantly influenced by the level of interdependence, or coupling, between the software modules within the project. This interdependence is often referred to as dependency.

For instance, consider a scenario where module (or class) A relies on another module (or class) B. We would describe this situation by saying that A is dependent on B, as illustrated below:

                                
                                     
  class B {
  }
  
  class A {
      private b = new B();
  }
  // class A depends on class B
  
                                
                            

The relationship between classes A and B can be visualized in a diagram showing their interdependence.

relation-between-classes-image

Before we delve into the Dependency Inversion Principle, it's important to understand the concepts of loose and tight coupling. This article will thoroughly explore the Dependency Inversion Principle in TypeScript and its implications for building robust, maintainable software systems.

Understanding Loose Coupling vs. Tight Coupling

Loose coupling is an indicator of a well-structured application, where components or modules have minimal dependencies on each other. This is often achieved when high-level modules depend on abstractions rather than concrete implementations. Loose coupling allows for easier changes in the implementation of low-level modules without affecting high-level modules. Conversely, tightly coupled systems, where components are highly dependent on one another, are less ideal.

In tightly coupled systems, changes in one module can have widespread effects on other dependent modules. Additionally, tightly coupled modules are harder to reuse and test since they require the inclusion of all their dependencies.

Achieving Loose Coupling in a TypeScript Application

To achieve loose coupling in a TypeScript application, developers can use various techniques such as interfaces and dependency injection. Here are some examples:

Example 1: Using Interfaces to Achieve Loose Coupling

In this example, we define an interface UserRepository which serves as an abstraction. The UserService class depends on this interface rather than a specific implementation.

                                
                                    
  // High-level module
  class UserService {
    constructor(private repository: UserRepository) {}
  
    save(user: User) {
      this.repository.save(user);
    }
  }
  
  // Abstraction (interface)
  interface UserRepository {
    save(user: User): void;
  }
  
  // Implementation of the abstraction
  class UserRepositoryImpl implements UserRepository {
    save(user: User) {
      // Save the user to the database
    }
  }
  
  // The UserService depends on the abstraction, not the implementation
  const userService = new UserService(new UserRepositoryImpl());
  
                                
                            
Example 2: Using Dependency Injection to Achieve Loose Coupling

This example demonstrates dependency injection, where the UserRepository instance is injected into the UserService class.

                                
                                    
  class UserService {
    constructor(private repository: UserRepository) {}
  
    save(user: User) {
      this.repository.save(user);
    }
  }
  
  class UserRepository {
    save(user: User) {
      // Save the user to the database
    }
  }
  
  // The UserRepository is injected into the UserService
  const userService = new UserService(new UserRepository());
  
                                
                            

Identifying Tight Coupling

Here are some examples of tightly coupled systems, where dependencies are directly instantiated or hardcoded:

Example 1: Direct Instantiation of a Dependent Class

In this example, UserService directly instantiates UserRepository, leading to tight coupling.

                                
                                    
  class UserService {
    repository = new UserRepository();  // Tight coupling
   
    save(user: User) {
      this.repository.save(user);
    }
  }
   
  class UserRepository {
    save(user: User) {
      // Save the user to the database
    }
  }
  
                                
                            
Example 2: Hardcoding a Dependent Class in a Function

Here, the saveUser function directly creates an instance of UserRepository, resulting in tight coupling.

                                
                                    
    function saveUser(user: User) {
      const repository = new UserRepository();  // Tight coupling
      repository.save(user);
    }
    
    class UserRepository {
      save(user: User) {
        // Save the user to the database
      }
    }
  
                                
                            

The Dependency Inversion Principle

The Dependency Inversion Principle (DIP) is a design principle that emphasizes that high-level modules should depend on abstractions, not on concrete implementations. This approach decouples high-level and low-level modules, facilitating easier changes in the low-level modules without impacting the high-level modules.

Emphasizing Abstractions in Dependency Management

Abstraction involves dealing with the broader concept of something without delving into specific details. One significant way to achieve abstraction in software development is through the use of interfaces.

Key Principle: Depend on Abstractions

The first principle in this context is that both high-level and low-level modules should depend on the same abstractions. By relying on an abstraction — such as an interface or an abstract class — you can replace its implementation with any other that adheres to the same interface. For example, think of a laptop charger plug:

Example: Laptop Plug and Dependency Inversion

A laptop plug can fit into any socket that matches the plug's interface of three pins. This flexibility allows the plug to work with a variety of sockets as long as they conform to the same interface.

Dependency Inversion in TypeScript

Let's explore an example of applying the dependency inversion principle in a shopping cart scenario:

                                
                                    
  // High-level module
  class ShoppingCartService {
    constructor(private paymentProcessor: PaymentProcessor) {}
  
    checkout(cart: ShoppingCart): boolean {
      return this.paymentProcessor.processPayment(cart);
    }
  }
  
  // Low-level module
  class PaymentProcessor {
    processPayment(cart: ShoppingCart): boolean {
      // Payment processing logic
      return true;
    }
  }
  
  // Abstraction
  interface PaymentProcessor {
    processPayment(cart: ShoppingCart): boolean;
  }
  
  // Implementation of the abstraction
  class StripePaymentProcessor implements PaymentProcessor {
    processPayment(cart: ShoppingCart): boolean {
      // Stripe API payment processing
      return true;
    }
  }
  
  // Usage
  const shoppingCartService = new ShoppingCartService(new StripePaymentProcessor());
  
                                
                            

In this example, ShoppingCartService (a high-level module) depends on the PaymentProcessor interface (an abstraction) rather than a concrete implementation. This decouples the service from the specific implementation, allowing easy substitution without modifying the high-level module.

Injecting Dependencies

Dependency Injection (DI) is a technique that helps decouple high-level modules from low-level modules by providing the necessary dependencies through abstractions. Here’s how you can implement DI in the shopping cart example:

                                
                                    
  class ShoppingCartService {
    constructor(private paymentProcessor: PaymentProcessor) {}

    public checkout(cart: ShoppingCart): boolean {
        // Logic
        return this.paymentProcessor.processPayment(cart);
    }
  }

  // Injecting dependencies
  const paymentProcessor = new StripePaymentProcessor();
  const shoppingCartService = new ShoppingCartService(paymentProcessor);
  
                                
                            

By injecting the PaymentProcessor dependency via the constructor, the system becomes more flexible, maintainable, and testable.

Comparing Dependency Inversion and Injection

Dependency inversion is a principle where high-level modules should depend on abstractions. Dependency injection is a technique to achieve this principle by passing dependencies to a class rather than creating them within the class. This allows for mock implementations during testing and runtime flexibility.

Benefits of Dependency Inversion
  • Easier Testing: High-level modules can be tested using mock implementations of low-level modules.
  • Code Reusability: High-level modules can be used in various contexts without modifying the low-level modules.
  • Flexibility and Maintainability: Promotes loose coupling, making the codebase more adaptable and easier to maintain.

Applying Dependency Inversion in TypeScript

To fully apply the dependency inversion principle, ensure both high-level and low-level modules depend on the same abstraction. For instance:

                                
                                    
  class PayPalPaymentProcessor implements PaymentProcessor {
    processPayment(cart: ShoppingCart): boolean {
        // PayPal API payment processing
        return true;
    }
  }
  
  class StripePaymentProcessor implements PaymentProcessor {
      processPayment(cart: ShoppingCart): boolean {
          // Stripe API payment processing
          return true;
      }
  }
  
                                
                            

This design pattern ensures that you can swap out the PaymentProcessor implementation without affecting the ShoppingCartService.

High-Level and Low-Level Modules

In dependency inversion, high-level modules provide more abstract functionality, typically interacting with users, while low-level modules offer specific implementations.

Example in TypeScript:
                                
                                    
  // High-level module
  class UserService {
    constructor(private repository: UserRepository) {}
  
    save(user: User): void {
      this.repository.save(user);
    }
  }
  
  // Low-level module
  class UserRepositoryImpl implements UserRepository {
    save(user: User): void {
      // Save to database
    }
  }
  
  // Abstraction
  interface UserRepository {
    save(user: User): void;
  }
  
  // Usage
  const userService = new UserService(new UserRepositoryImpl());

                                
                            

Here, UserService depends on the UserRepository interface, allowing flexibility in changing the repository implementation.

Dependency Inversion in SOLID Principles

The dependency inversion principle is integral to the SOLID principles, promoting flexible, maintainable, and testable code. It aligns with:

  • Single Responsibility Principle: Separates Separates concerns into different modules.
  • Open/Closed Principle: Makes the code open for extension but closed for modification.
  • Liskov Substitution Principle: Allows substituting different implementations without affecting the high-level module.
  • Interface Segregation Principle: Promotes the use of small, specific interfaces

Inversion of Control (IoC)

Inversion of Control (IoC) is a broader principle where components rely on external sources rather than controlling their dependencies. Dependency injection is a specific implementation of IoC.

Example Without IoC:
                                
                                    
  class ShoppingCart {
    private belt: Seatbelt = new CartSeatbelt();
  }

                                
                            
Example With IoC:
                                
                                    
  class ShoppingCart {
    constructor(private belt: Seatbelt) {}
  } 

                                
                            

Tools for Dependency Injection/IoC

Several libraries facilitate IoC in TypeScript:
  • InversifyJS: An IoC container for TypeScript projects used by companies like Microsoft, Amazon, and Slack.
Installation:
                                
                                    
  npm install inversify reflect-metadata --save

                                
                            
Configuration:
                                
                                    
  // tsconfig.json
  {
      "compilerOptions": {
          "target": "es5",
          "lib": ["es6"],
          "types": ["reflect-metadata"],
          "module": "commonjs",
          "moduleResolution": "node",
          "experimentalDecorators": true,
          "emitDecoratorMetadata": true
      }
  }

                                
                            
Interfaces:
                                
                                    
  // interfaces.ts
  export interface ShoppingCart {
    getTotalCost(): number;
  }
  export interface Item {
    getPrice(): number;
  }

                                
                            
Types:
                                
                                    
  // types.ts
  const TYPES = {
    ShoppingCart: Symbol.for("ShoppingCart"),
    Item: Symbol.for("Item"),
  };
  export { TYPES };

                                
                            
Entities:
                                
                                    
  import { injectable, inject, multiInject } from "inversify";
  import "reflect-metadata";
  import { Item, ShoppingCart } from "./interfaces";
  import { TYPES } from "./types";
  
  @injectable()
  class iPhone implements Item {
    getPrice(): number {
      return 100;
    }
  }
  
  @injectable()
  class Doritos implements Item {
    getPrice(): number {
      return 10;
    }
  }
  
  @injectable()
  class CheapCart implements ShoppingCart {
    private _items: Item[];
  
    public constructor(@multiInject(TYPES.Item) items: Item[]) {
      this._items = items;
    }
  
    getTotalCost(): number {
      return this._items.reduce((prev, curr) => prev + curr.getPrice(), 0);
    }
  }
  export { CheapCart, Doritos, iPhone };

                                
                            
Container:
                                
                                    
  import { Container } from "inversify";
  import { TYPES } from "./types";
  import { ShoppingCart, Item } from "./interfaces";
  import { CheapCart, Doritos, iPhone } from "./entities";
  
  const myContainer = new Container();
  myContainer.bind<ShoppingCart>(TYPES.ShoppingCart).to(CheapCart);
  myContainer.bind<Item>(TYPES.Item).to(iPhone);
  myContainer.bind<Item>(TYPES.Item).to(Doritos);
  
  export { myContainer };

                                
                            
Usage:
                                
                                    
  import { myContainer } from "./inversify.config";
  import { TYPES } from "./types";
  import { ShoppingCart } from "./interfaces";
  
  const cheapCart = myContainer.get<ShoppingCart>(TYPES.ShoppingCart);
  console.log(cheapCart.getTotalCost());

                                
                            

Conclusion

In conclusion, understanding and applying the Dependency Inversion Principle (DIP) is crucial for creating flexible, maintainable, and testable software. By ensuring that high-level and low-level modules depend on abstractions rather than concrete implementations, we can achieve loose coupling and enhance the reusability of our code.

In our TypeScript examples, we demonstrated how the DIP allows us to swap implementations easily and how dependency injection can facilitate this process by injecting dependencies into classes. This approach not only promotes better separation of concerns but also makes unit testing more straightforward by allowing us to mock dependencies.

Furthermore, implementing the Inversion of Control (IoC) principle, of which dependency injection is a subset, helps in decoupling the components of an application, making it easier to manage and test. Libraries like InversifyJS provide robust tools to enforce these principles in TypeScript projects, enabling developers to adhere to clean code practices and design patterns.

By integrating these principles into your development workflow, you can ensure that your applications are scalable, adaptable to change, and maintainable in the long run. Adopting the SOLID principles, particularly the Dependency Inversion Principle, is a step towards building robust and high-quality software systems.

Let's develop your ideas into reality