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
Below is a diagram representing the relationship between 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:
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);
}
}
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')
}
}
}
Let’s represent the relationship between both classes as follows:
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");
}
}
}
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");
}
}
}
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);
}
}
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()
...
The class diagram will look like this now:
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);
}
}
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()
The class diagram representing the relationship among the classes looks like this:
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.
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)
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.