Editor’s note: This guide was last reviewed and updated on 25 October 2024.

In the SOLID design principles, the dependency inversion principle (DIP) 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 that:
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.
To understand how this abstraction principle applies in software design, it’s important to see how developers approach breaking down complex systems into smaller, manageable components 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.
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.
Some negatives of a tightly coupled system include:
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 in TypeScript:
// 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
}
}
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:

Dependency injection (DI) 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 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.
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 because 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.
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 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 to test 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.
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.
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:

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:
To learn more about this, check out our article on applying the SOLID principles to TypeScript.
Another commonly used design principle is Inversion of Control, or IoC. Here, the developer designs software components in such a way that they rely on an external source to work. In simpler words, it helps to decouple the bits and pieces in a system, thus making them reusable and easier to test.
Let’s demonstrate this with a code sample. Here is a snippet that doesn’t implement IoC:
class ShoppingCart {
belt: Seatbelt;
constructor() {
this.belt = new CartSeatbelt();
}
}
As you can see, the ShoppingCart class does not rely on an external Seatbelt instance. However, this is if we were to rewrite this code using the IoC principle:
class ShoppingCart {
belt: Seatbelt;
constructor(providedBelt: Seatbelt) {
//constructor now requires an existing Seatbelt instance..
this.belt = providedBelt;
}
}
In this case, our ShoppingCart class is now dependent on an external Seatbelt instance. Furthermore, notice that in the latter snippet, we are injecting another component (Seatbelt) to make our program work. Thus, it would be safe to say that IoC promotes the dependency injection rule. To put it simply, IoC is just a principle, whereas dependency injection is an implementation.
As discussed above, IoC is just a generic pattern and can have multiple implementations, with dependency injection being one of them. We can illustrate this better via this diagram:

Here is a code sample to showcase dependency injection and another IoC implementation, Service Locator:
//This example uses Dependency Injection and is a valid IoC implementation
class ShoppingCart {
belt: Seatbelt;
constructor(providedBelt: Seatbelt) {
//constructor now requires an existing Seatbelt instance..
this.belt = providedBelt;
}
}
// This example uses Service Locator and is a valid IoC implementation.
// This is NOT an example of Dependency Injection
//https://stackify.com/service-locator-pattern/
class ShoppingCartLocator {
locator: ShoppingCartLocator;
cheapCart: ShoppingCart;
constructor() {
// configure and instantiate an array of products within this cart
const items: Products = [];
items.append(new Banana());
items.append(new Doritos());
//send this array to another implementation of ShoppingCart
const cheapCarts = new CheapShoppingCart(carts);
}
getInstance(): ShoppingCartLocator {
if (locator == null) {
locator = new ShoppingCartLocator();
}
return locator;
}
getShoppingCart() {
return cheapCart;
}
}
There are a few libraries in TypeScript that help developers implement the IoC design principle in their code.
InversifyJS is an IoC container for TypeScript projects. Thanks to its friendly API and popularity, major companies like Microsoft, Amazon, and Slack use Inversify to enforce Clean Code Principles in their apps.
To install Inversify, run this bash command:
npm install inversify reflect-metadata --save touch tsconfig.json #configure Typescript support
Next, we need to configure our TypeScript settings. To do so, write this code in your tsconfig.json file:
{
"compilerOptions": {
"target": "es5",
"lib": ["es6"],
"types": ["reflect-metadata"],
"module": "commonjs",
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
When that’s done, let’s now start by defining our interfaces:
//file name: interfaces.ts
//create two interfaces that will be extended later
//ShoppingCart represents a real-world cart with items in it.
export interface ShoppingCart {
getTotalCost(): number;
}
//Item represents an item in a grocery store
export interface Item {
getPrice(): number;
}
As the second step, we now have to tell Inversify our types. The library uses them as identifiers for our dependencies:
//file name: type.ts
const TYPES = {
ShoppingCart: Symbol.for("ShoppingCart"),
Item: Symbol.for("Item"),
};
export { TYPES };
After this, we need to create a few entities that will make use of our interfaces. These entities serve as dependencies:
import { injectable, inject, multiInject } from "inversify";
import "reflect-metadata";
import { Item, ShoppingCart } from "./interfaces";
import { TYPES } from "./types";
//tell Inversify that this entity is a dependency
//all classes must be decorated with this decorator
@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[];
//via the @multiInject decorator, Typescript will mark Items
//as a dependency for the CheapCart class
public constructor(@multiInject(TYPES.Item) items: Item[]) {
this._items = items;
}
//add the prices of all items in the cart and return the sum:
getTotalCost(): number {
return this._items.reduce((previousValue, currentValue) => {
return previousValue + currentValue.getPrice();
}, 0);
}
}
export { CheapCart, Doritos, iPhone };
Now that our entities have been created, let’s create a container for them:
//file name: inversify.config.ts
import { Container } from "inversify";
import { TYPES } from "./types";
import { ShoppingCart, Item } from "./interfaces";
import { CheapCart, Doritos, iPhone } from "./entities";
const myContainer = new Container(); //create a new container
//within this container, add our CheapCart and all instances of Item
myContainer.bind<ShoppingCart>(TYPES.ShoppingCart).to(CheapCart);
myContainer.bind<Item>(TYPES.Item).to(iPhone);
myContainer.bind<Item>(TYPES.Item).to(iPhone);
myContainer.bind<Item>(TYPES.Item).to(Doritos);
export { myContainer };
That’s it! To use this container, write this snippet of code:
//main.ts
import { myContainer } from "./inversify.config";
import { TYPES } from "./types";
import { ShoppingCart } from "./interfaces";
//get our CheapCart instance and return the total cost:
const cheapCart = myContainer.get<ShoppingCart>(TYPES.ShoppingCart);
console.log(cheapCart.getTotalCost());
Let’s test it out. To run this code, run this command:
ts-node main.ts
Our expected output should be the cost of two iPhone objects and one Doritos instance:
![]()
TypeDI is another library for managing dependency injections in TypeScript. It includes the following features:
To install TypeDI, run this terminal command:
npm install typedi reflect-metadata
This snippet of code shows the creation of a DI container:
import { Container, Service } from 'typedi';
import "reflect-metadata";
@Service()
class ExampleInjectedService {
printMessage() {
console.log('I am alive!');
}
}
@Service()
class ExampleService {
constructor(
// because we annotated ExampleInjectedService with the @Service()
// decorator, TypeDI will automatically inject an instance of
// ExampleInjectedService within the ExampleService class:
public injectedService: ExampleInjectedService
) {}
}
const serviceInstance = Container.get(ExampleService);
// Get an instance of ExampleService from TypeDI
serviceInstance.injectedService.printMessage();
If the code was successful, we should see the words I am alive! printed in the console:
![]()
For more information on implementing dependency injection with TypeDI, check out this guide.
The dependency inversion principle 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.
In this article, we explored how adopting dependency inversion supports loose coupling in TypeScript applications. By adhering to this principle of inversion, high-level modules depend on abstractions, which enhances flexibility and maintainability. Inversion of control and dependency injection reinforcs this, emphasizing the importance of decoupling for more adaptable code.
Additionally, understanding dependency inversion vs. dependency injection highlights the effectiveness of decoupled architecture. Incorporating inversion of dependency ensures that both high-level and low-level modules share abstractions, resulting in testable and reusable software that adapts to change more efficiently.
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.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now
From basic syntax and advanced techniques to practical applications and error handling, here’s how to use node-cron.

The Angular tree view can be hard to get right, but once you understand it, it can be quite a powerful visual representation.

Build a fast, real-time app with Relay 17 to leverage features like optimistic UI updates, GraphQL subscriptions, and seamless data syncing.

Simplify component interaction and dynamic theming in Vue 3 with defineExpose and for better control and flexibility.
6 Replies to "Understanding the dependency inversion principle in TypeScript"
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!
Thank you for such great article.