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:
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
- The dependency inversion principle
- Examples of dependency inversion in TypeScript
- Dependency inversion vs. injection
- Applying the dependency inversion principle in TypeScript
- High-level and low-level modules
- How the dependency inversion principle fits into the SOLID principles
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:
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:
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:
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.
LogRocket: 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.
Try it for free.
Very nice article on Dependency Inversion!
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!
Thanks for the tip — would you mind pointing out the specific code blocks where the typos occur?
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.
Nicely Explained!