Samuel Olusola Software engineer (JS stack, GoLang incoming…) and student of computer science at the University of Lagos.

Understanding the dependency inversion principle in TypeScript

8 min read 2379

Understanding the Dependency Inversion Principle in TypeScript

Editor’s note: This guide to understanding the dependency inversion principle in TypeScript was last updated on 13 January 2023 to include sections to define high-level/low-level modules, compare loose coupling and tight coupling, have a section on dependency inversion vs. dependency injection, and explain the SOLID principles of design.

When building software, developers usually break down large problems into smaller ones. We implement the solutions to the subproblems in components or modules, then compose those components together to create a system that solves the original problem.

One thing that determines the quality of the systems and applications we build is the degree of interdependence between the software modules that make up the project. This degree of interdependence is referred to as a dependency or coupling.

For example, if we have a module (or class) A that uses another module (or class) B, we say that A depends on B, like so:

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

Below is a diagram representing the relationship between A and B:

Diagram Representing Interdependence

However, before we can talk about the dependency inversion principle, we have to first discuss the concept of loose versus tight coupling. In this article, we will take a deep dive into understanding the dependency inversion principle in TypeScript.

Jump ahead:

Understanding loose coupling vs. tight coupling

Loose coupling, where you have minimal interdependence between components or modules of a system, is a sign of a well-structured application. When high-level modules depend on abstractions, it promotes loose coupling, making it easier to change the implementation of the low-level modules without affecting the high-level modules. On the other hand, a tightly coupled system, where components are dependent on one another, is not ideal.

One of the negatives of a tightly coupled system is that changes in one module will have a ripple effect in other modules that depend on the modified module. Another negative is that a module will become challenging to reuse and test because its dependent modules must be included.

Loose coupling in a TypeScript app

As stated above, loose coupling is a sign of a well-structured application. So, how do we achieve this? Below are some examples of how you can achieve loose coupling in a TypeScript application:

// Example 1: Using interfaces to achieve loose coupling

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

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

Now that we know what loose coupling looks like, here are some examples of tight coupling:

// Example 1: Directly instantiating a dependent class

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

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 is a design principle that states that high-level modules should depend on abstractions rather than concrete implementations. This helps decouple the high-level and low-level modules, making it easier to change the low-level ones without affecting the high-level ones.

The dependency inversion principle helps us couple software modules loosely. The principle was developed after many years of coupling software modules, and it states:

  • High-level modules should not import anything from low-level modules; they should both depend on abstractions
  • Abstractions should not rely on concrete implementations; concrete implementations should depend on abstractions

Depending on abstractions

When we abstract, we can say that we are dealing with the overall idea of something without caring about the details. One important way we abstract things is by using an interface.

The first principle states that high-level and low-level modules should depend on the same abstractions. If a module depends on an abstraction — an interface or abstract class — we can swap its dependency for any other implementation that adheres to the interface. For a real-life analogy, consider the plug of a laptop charger below:

Example of a Laptop Plug to Show Dependency Inversion

This plug can be connected to any socket as long as the socket satisfies the interface of the three pins. This way, the plug can be used with a variety of sockets so long as they meet the requirements of the interface.

Examples of dependency inversion in TypeScript

Let’s take a look at an example that illustrates the dependency inversion principle in the context of a shopping cart:

// High-level module
class ShoppingCartService {
  constructor(private paymentProcessor: PaymentProcessor) {}

  checkout(cart: ShoppingCart) {
    return this.paymentProcessor.processPayment(cart);
  }
}

// Low-level module
class PaymentProcessor {
  processPayment(cart: ShoppingCart) {
    // Process the payment for the items in the shopping cart
  }
}

// Abstraction
interface PaymentProcessor {
  processPayment(cart: ShoppingCart): boolean;
}

// Implementation of the abstraction
class StripePaymentProcessor implements PaymentProcessor {
  processPayment(cart: ShoppingCart): boolean {
    // Use the Stripe API to process the payment for the items in the shopping cart
  }
}

// Now the ShoppingCartService depends on the abstraction, not the implementation
const shoppingCartService = new ShoppingCartService(new StripePaymentProcessor());

In this example, the ShoppingCartService class is a high-level module that provides a more abstract interface for checking out shopping carts. It depends on the PaymentProcessor class, a low-level module, to process the payment for the items in the shopping cart.

However, rather than depending on the concrete PaymentProcessor class, it depends on the PaymentProcessor interface, which is an abstraction. This decouples the ShoppingCartService from the PaymentProcessor implementation and allows you to easily change the implementation of the PaymentProcessor without affecting the ShoppingCartService. Here’s a visualization of that in action:

The Dependency Inversion Principle in TypeScript

Injecting dependencies

Dependency injection is a technique that allows us to decouple high-level modules from low-level modules by providing an abstraction for the low-level modules. In our shopping cart example, we can use dependency injection to provide an instance of the PaymentProcessor interface to the ShoppingCartService class.

This allows us to change the implementation of the PaymentProcessor without affecting the ShoppingCartService. Below is an example of how we can inject the PaymentProcessor dependency into the ShoppingCartService class:

class ShoppingCartService {
    private paymentProcessor: PaymentProcessor;

    constructor(paymentProcessor: PaymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }

    public checkout(cart: Cart) {
        // do some logic
        this.paymentProcessor.processPayment(cart);
    }
}

In this example, we are using the constructor of ShoppingCartService to inject the PaymentProcessor dependency. This creates a new instance of the PaymentProcessor and assigns it to the paymentProcessor property of the ShoppingCartService.

To use this service in our application, we can create an instance of the ShoppingCartService class and pass in an instance of the PaymentProcessor interface, as shown below:

const paymentProcessor = new PayPalPaymentProcessor();
const shoppingCartService = new ShoppingCartService(paymentProcessor);

Here, dependency injection helps us achieve loose coupling, making the system more flexible, maintainable, and easier to test.

Dependency inversion vs. injection

Dependency injection is a technique for achieving dependency inversion. In dependency injection, a class or module receives its dependencies as arguments to its constructor or functions rather than creating them themselves. This allows the dependencies to be replaced with mock implementations during testing and makes it easier to change them at runtime.

Benefits of dependency inversion

One reason to use dependency inversion is to make it easier to test high-level modules. By depending on abstractions, high-level modules can be tested using mock implementations of the low-level modules rather than needing to use the real low-level modules. This can make testing faster and more reliable since it avoids the need to set up a real database or other external resources.

Another reason to use dependency inversion is to make it easier to reuse high-level modules. By depending on abstractions, high-level modules can be used in different contexts without needing to change the low-level modules they depend on. This can make it easier to reuse code and avoid duplication. The dependency inversion principle can help create more flexible, maintainable, and testable software.

Applying the dependency inversion principle in TypeScript

The dependency inversion principle states that high-level modules should not depend on low-level modules, but rather both should depend on abstractions. In our shopping cart example, we have applied this principle by creating an interface  PaymentProcessor that defines the methods a payment processor should have.

This allows the ShoppingCartService class, a high-level module, to depend on the PaymentProcessor interface rather than on a specific implementation of a payment processor. However, this is not enough to fully apply the dependency inversion principle.



We also need to ensure that the low-level modules, such as the PayPalPaymentProcessor and StripePaymentProcessor classes depend on the same abstraction. We can achieve this by implementing the PaymentProcessor interface in these classes:

class PayPalPaymentProcessor implements PaymentProcessor {
    public processPayment(cart: Cart) {
        // implementation for processing payment with PayPal
    }
}

class StripePaymentProcessor implements PaymentProcessor {
    public processPayment(cart: Cart) {
        // implementation for processing payment with Stripe
    }
}

We have achieved a loose coupling between the modules by having the high-level and low-level modules depend on the same abstraction. This allows us to easily swap out the implementation of the PaymentProcessor without affecting the ShoppingCartService class.

Furthermore, this design pattern allows for easier testing as we can create mock implementations of the PaymentProcessor interface for testing the ShoppingCartService without relying on a real payment processor. In summary, by applying the dependency inversion principle in TypeScript, we can create a more flexible, maintainable, and easier-to-test codebase.

High-level and low-level modules

In the context of the dependency inversion principle, high-level modules provide more abstract, general functionality in your application. They are typically closer to the user and may depend on lower-level modules to perform more specific tasks.

Low-level modules, on the other hand, provide more concrete, specific functionality. They are typically lower in the application’s architecture and may be depended upon by higher-level modules to perform more general tasks.

For example, in the case of a web application, the high-level module might be the controller that handles HTTP requests from the user and coordinates the actions of the low-level modules, such as the repository that interacts with the database.

An example of high and low-level modules in TypeScript

By following the dependency inversion principle, you can design your application so that the high-level modules depend on abstractions rather than concrete implementations of the low-level modules.

This can make your code more flexible and easier to maintain because it reduces the coupling between components and allows you to easily change the implementation of the low-level modules without affecting the high-level modules. Here is an example of high-level and low-level modules in TypeScript:

// High-level module
class UserService {
  constructor(private repository: UserRepository) {}

  save(user: User) {
    this.repository.save(user);
  }
}

// Low-level module
class UserRepository {
  save(user: User) {
    // Save the user to the database
  }
}

// Abstraction
interface UserRepository {
  save(user: User): void;
}

// Implementation of the abstraction
class UserRepositoryImpl implements UserRepository {
  save(user: User) {
    // Save the user to the database
  }
}

// Now the UserService depends on the abstraction, not the implementation
const userService = new UserService(new UserRepositoryImpl());

In this example, the UserService class is a high-level module that provides a more abstract interface for saving users. It depends on the UserRepository class, a low-level module, to save the user to the database.

However, rather than depending on the concrete UserRepository class, it depends on the UserRepository interface, which is an abstraction. This decouples the UserService from the UserRepository implementation and allows you to easily change the implementation of the UserRepository without affecting the UserService. Here’s a visualization of this:

Diagram Illustrating High-Level and Low-Level Modules

How the dependency inversion principle fits into the SOLID principles

The SOLID principles are guidelines that help you design more maintainable and scalable software. The dependency inversion principle states that high-level modules should depend on abstractions rather than low-level modules.

This helps to decouple the components in your application and can make your code more flexible, maintainable, and easier to test. The dependency inversion principle relates to the SOLID principles in the following ways:

  • Single-responsibility principle: The dependency inversion principle helps to adhere to the single-responsibility principle by allowing you to separate concerns into different modules. For example, the high-level module can provide a more abstract interface, while the low-level module can focus on implementing the details
  • Open-closed principle: The dependency inversion principle can help you design your code in a way that is open for extension but closed for modification. By depending on abstractions rather than concrete implementations, you can more easily extend the functionality of your code without modifying existing code
  • Liskov substitution principle: The dependency inversion principle can help you adhere to the Liskov substitution principle by allowing you to substitute different implementations of a module without affecting the high-level module
  • Interface segregation principle: The dependency inversion principle promotes the use of small, specific interfaces that only expose the methods that are needed by the high-level module. This can help you adhere to the interface segregation principle by not forcing the high-level module to depend on unnecessary methods
  • Dependency inversion principle: The dependency inversion principle helps you design more maintainable and scalable software by promoting loose coupling between components and adhering to the SOLID principles

To learn more about this, check out our article on applying the SOLID principles to TypeScript.

Conclusion

In this article, we have gone through what a dependency is and why we want loose coupling between the components or modules making up our TypeScript application. We also looked at the dependency inversion principle, a practical example of how to apply it, and how it enables us to swap implementations easily.

Dependency inversion is one of the popular SOLID principles, which is an acronym for the first five object-oriented design principles by Robert C. Martin. You can learn more about the rest of the SOLID principles here.

: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

.
Samuel Olusola Software engineer (JS stack, GoLang incoming…) and student of computer science at the University of Lagos.

5 Replies to “Understanding the dependency inversion principle in TypeScript”

  1. Hi, really nice article! A couple of typos in the code examples. You’re writing log.info instead of log.error when an exception occurs. Cheers!

    1. Thanks for the tip — would you mind pointing out the specific code blocks where the typos occur?

  2. A couple of problems with this principle. High level and low level is vaguely defined. If you apply this to the highest levels, this works fine. But the lower you go, the more this will feel the effects of an extra pointer to resolve or an extra function call. So, make sure that in your language, this results in, as much as possible, zero cost abstractions. Interfaces and Traits are typically fine, but watch out with proxies, abstract classes or any form of wrapper constructs.

Leave a Reply