Editor’s note: This guide was last updated by Hussain Arif on 2 May 2024 to compare dependency injection and Inversion of Control (IoC), as well as to demonstrate tools for implementing dependency inversions and injections in TypeScript, such as InversifyJS and TypeDI.
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.
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.
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 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.
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 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.
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 (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 does not 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:
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. We also learned about Inversion of Control, its tools, and how it promotes dependency injection.
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 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 nowThe use cases for the ResizeObserver API may not be immediately obvious, so let’s take a look at a few practical examples.
Automate code comments using VS Code, Ollama, and Node.js.
Learn to build scalable micro-frontend applications using React, discussing their advantages over monolithic frontend applications.
Build a fully functional, real-time chat application using Laravel Reverb’s backend and Vue’s reactive frontend.
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.