DEV Community

Cover image for Understanding the dependency inversion principle in TypeScript
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Understanding the dependency inversion principle in TypeScript

Written by Samuel Olusola✏️

Whenever we are building software, developers usually like to 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 of the things 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
Enter fullscreen mode Exit fullscreen mode

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

Diagram representing the interdepence between class A and B

But before we can talk about the dependency inversion principle, we have to first discuss the concept of loose versus tight coupling.

Low (or loose) coupling vs. tight coupling

One of the signs of a well-structured application (or computer system in general) is having very minimal interdependence (low coupling) between the components or modules of the system. A tightly coupled system of components is not desirable.

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

The dependency inversion principle

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

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

Depending on abstractions

When we abstract, we can say that we are dealing with just 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 both high-level and low-level modules should depend on the same abstractions. If a module depends on an abstraction — say, an interface or abstract class — we can swap the its dependency for any other implementation that adheres to the interface.

For a real-life analogy, consider the plug of a laptop charger below:

laptop charger plug

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.

An example of dependency inversion in TypeScript

Suppose we have a Logger class for logging information in our project, defined in a file logger.ts, as follows:

export class Logger {
  log(message: string) {
    console.log(message);
  }
  error(message: string) {
    console.error(message);
  }
}
Enter fullscreen mode Exit fullscreen mode

We are exporting the Logger class because it’s expected to be used elsewhere. We are using it in another module located in a file named user-service.ts:

import { Logger } from "./logger"; // this module depends on concrete implementatioon of logger

class UserService {
  private logger = new Logger();

  async getAll() {
    try {
      this.logger.log('Retrieving all users...');
      return [];
    } catch (error: any) {
      this.logger.log(`An error occurred: ${error?.message}`);
      throw new Error('Something went wrong')
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Let’s represent the relationship between both classes as follows:

userservice vs logger relationship

The UserService class depends on the Logger class. This is first made obvious from the fact that we have to import the Logger class into the UserService class.

The line private logger = new Logger(); also reveals that the UserService is tightly coupled to the current implementation of Logger. It depends on a concrete implementation.

Injecting dependencies

UserService cannot be tested separately on its own without the Logger. One way to improve the testability of UserService is to change the way we supply the Logger object.

Currently, a Logger object is instantiated in UserService by the line private logger = new Logger();. We can transfer the task of creating the object to another part of our code, and we just use the object directly, i.e., someone else creates the dependency object and injects it into UserService. This is called dependency injection.

The code snippet below shows an example of how to do this:

import { Logger } from "./logger";

class UserService {
  private logger: Logger;
  // the constructor receives the object to inject from another source
  constructor(logger: Logger) {
    this.logger = logger;
  }
  async getAll() {
    try {
      this.logger.log("Retrieving all users...");
      return [];
    } catch (error: any) {
      this.logger.log(`An error occurred: ${error?.message}`);
      throw new Error("Something went wrong");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The Logger instance is now being injected into the UserService class via the constructor, but it still depends on a concrete implementation of Logger because the logger property is still typed as Logger.

Applying the dependency inversion principle in TypeScript

As suggested by the principle, we want to make sure that UserService and Logger both depend on abstractions. We don’t want to import anything from the lower level class (Logger, in this case) into the high level class (UserService).

Let’s introduce an interface and let UserService depend on it:

export interface ILogger {
  log(message: string): void;
  error(message: string): void;
}

class UserService {
  private logger: ILogger;
  constructor(logger: ILogger) {
    this.logger = logger;
  }
  async getAll() {
    try {
      this.logger.log("Retrieving all users...");
      return [];
    } catch (error: any) {
      this.logger.log(`An error occurred: ${error?.message}`);
      throw new Error("Something went wrong");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Above, we have an ILogger interface defined in UserService. This service defines what the UserService expects of a logger; it’s not tied to a specific logger class.

Any class implementing the interface can be injected into UserService. Note that the ILogger interface doesn’t have to be defined in the user-service.ts file, it can (and should) be extracted into its own file.

Now, we update the Logger class to implement the interface defined by UserService:

import { ILogger } from "./user-service";

export class Logger implements ILogger {
  log(message: string) {
    console.log(message);
  }
  error(message: string) {
    console.error(message);
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that UserService is not importing anything from Logger in its implementation, and both classes depend on an interface (an abstraction).

We can then do this anywhere we want to use UserService:

...
// inject any class that implements ILogger into UserService
const userService = new UserService(new Logger()) 
userService.getAll()
...
Enter fullscreen mode Exit fullscreen mode

The class diagram will look like this now:

dependency inversion class diagram

Notice the direction of the arrows. In the previous class diagram, there was an arrow from UserService to Logger indicating that it depends on Logger. Now we have an arrow from UserService to the interface ILogger. Also, we have an arrow from Logger to ILogger. Both of them depend on an abstraction.

We have successfully inverted the direction of the dependency. Inversion doesn’t mean that the lower-level layer depends directly on the higher-level level, it means that both layers should depend on abstractions — and these abstractions expose the behavior needed by the higher-level layers.

Swapping different implementations

Now to show one of the benefits of applying the dependency inversion principle, let’s assume that our project is a Node.js project, and we want to use a third-party logger library for logging. Winston is one of the popular logger libraries we can choose. Because this post is not a post about logging, I won’t be going into the details of how to use Winston.

Say you have installed winston and set it up as described in the docs, you can create a class that implements ILogger as follows in a file named winston-logger.ts:

import { ILogger } from "./user-service";
import * as winston from "winston";

export class WinstonLogger implements ILogger {
  private winstonLogger: winston.Logger;

  constructor() {
    this.winstonLogger = winston.createLogger({
      transports: [
        new winston.transports.File({
          format: winston.format.combine(
            winston.format.json(),
            winston.format.timestamp()
          ),
          filename: "logs/combined.log",
        }),
      ],
    });
  }

  log(message: string): void {
    this.winstonLogger.log(message);
  }
  error(message: string): void {
    this.winstonLogger.error(message);
  }
}
Enter fullscreen mode Exit fullscreen mode

We set up the winston.transports.File transport so that logs can be saved into a file located at the filepath logs/combined.log, relative to the project root. The WinstonLogger class also implements the ILogger interface.

UserService can make use of the WinstonLogger, because it adheres to the interface that UserService implements as follows:

const userService = new UserService(new WinstonLogger())
userService.getAll()
Enter fullscreen mode Exit fullscreen mode

The class diagram representing the relationship among the classes looks like this:

class diagram with WinstonLogger

Suppose that after some time you decide that the winston logger library was not the best logger for your project and you want to use Bunyan, what do you do? You just need to create a BunyanLogger class that implements the ILogger interface and it is ready to be used by the UserService.

Applying the dependency inversion principle also ensures that we are not tied to a specific third-party logging library. We are not in control of the library, but we are in control of how our project code interacts with it.

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 easily swap implementations.

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.


Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

Write More Readable Code with TypeScript 4.4

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

Top comments (1)

Collapse
 
spykelionel profile image
Ndi Lionel

I enjoyed reading this. All examples are pretty need. Also, the way you narrowed coupling to dependency is a plus for me.
Thanks for this.