SOLID: Dependency Inversion Principle
Photo by Aida L from Unsplash

SOLID: Dependency Inversion Principle

All programs are composed of functions and data structures and the SOLID principles introduced by Robert C. Martin, help us group our code into modules and advise how to interconnect them. The whole point of application architecture is to manage change. Software requirements change all the time and we as developers need to make sure that these changes don't break existing code.

The goal of the SOLID principles is to build software structures like Lego blocks that can be easy to change, understand and swap out.

There are 5 principles that make up the acronym are:

Each of these principles can stand on its own.

In this post, I will attempt to explain the DIP (Dependency Inversion Principle).

The DIP tells us that the most flexible systems are those in which source code dependencies refer only to abstractions, not implementations.

Put another way, high-level modules should not depend on low-level modules, but should depend on their abstractions.

what?

Ok, let's start by identifying the problem we're trying to solve?. Let's create a low-level user repository module/class:

1//domain/repositories/user-repository.ts 2import { User } from "../models/user"; 3export class UserRepository{ 4 getUsers(): Promise<User[]> { 5 ... 6 } 7 getUser(id: number): Promise<User> { 8 ... 9 } 10 deleteUser(id: number): Promise<void> { 11 ... 12 } 13 updateUser(id: number, data: User): Promise<void> { 14 ... 15 } 16 createUser(data: User): Promise<void> { 17 ... 18 } 19}

and a high-level class, that uses/depends on the user repository:

1//domain/use-cases/get-all-users.ts 2import { UserRepository } from "../../domain/repositories/user-repository"; 3 4export class GetAllUsers { 5 private userRepository; 6 7 constructor() { 8 this.userRepository = new UserRepository(); 9 } 10 11 execute() { 12 return this.userRepository.getUsers(); 13 } 14}

and to use the high-level class we do the following:

1import { GetAllUsers } from "./domain/use-cases/get-all-users"; 2 3(async () => { 4 const useCase = new GetAllUsers(); 5 const result = await useCase.execute(); 6 console.log(result); 7})();

The problem lies within the GetAllUsers class. We have a direct or hard dependency between the "GetAllUsers" and "UserRepository" Class as indicated by the import statement and the creation of a new UserRepository instance.

If we were importing a system, framework library or any static dependency, then it should be ok, however, for something like an actively developing module as user repository it would be advisable not to have a hard dependency as we do here within the high-level module

Why is this a problem?

Well, if we tightly couple the high-level component("GetAllUsers") to low-level dependencies (user repository) then changes are risky. If we however depend on the interface / abstraction of the dependency then we reduce the risk to higher level components.

Let's change the code to illustrate. We split the user repository into an interface and implementation

1//domain/repositories/user-repository.ts 2import {User} from '@domain/model/User' 3export interface UserRepository { 4 getUsers(): Promise<User[]> 5 getUser(id: number): Promise<User> 6 deleteUser(id: number): Promise<void> 7 updateUser(id: number, data: User): Promise<void> 8 createUser(data: User): Promise<void> 9}
1//./domain/repositories/user-repository-impl.ts 2import {UserRepository} from '@domain/repositories/user-repository' 3 4export class UserRepositoryImp implements UserRepository { 5 getUsers(): Promise<User[]> { 6 ... 7 } 8 getUser(id: number): Promise<User> { 9 ... 10 } 11 deleteUser(id: number): Promise<void> { 12 ... 13 } 14 updateUser(id: number, data: User): Promise<void> { 15 ... 16 } 17 createUser(data: User): Promise<void> { 18 ... 19 } 20}

We do this so that the high-level module/class would need to change

1//./domain/use-cases/get-all-users.ts 2import { UserRepository } from "../../domain/repositories/user-repository"; 3 4export class GetAllUsers { 5 private userRepository; 6 7 constructor(userRepository: UserRepository) { 8 this.userRepository = userRepository; 9 } 10 11 execute() { 12 return this.userRepository.getUsers(); 13 } 14}

Now we have the high-level class not directly dependent on the lower-level module but rather its interface/abstraction. The high-level also does not "new up" a user repository implementation

We put them all together as follows:

1import { GetAllUsers } from "./domain/use-cases/get-all-users"; 2 3(async () => { 4 const userRepository = new UserRepoistory(); 5 const useCase = new GetAllUsers(userRepository) 6 const result = await useCase.execute() 7 console.log(result); 8})();

What you see now is the creation of dependencies has been inverted. Classes of lower-level are created first then injected through the constructor into the high-level class.

This is great for swapping out dependencies especially when unit testing, as we can test higher level modules in isolation by providing them with mocks dependencies.