Writing software so that it’s easy to change is not an easy task. Fortunately, there are a number of principles that, if understood and followed, will make your software easier to change. The reason you want to have easily changeable software is that the world does not stay the same, and software is not immune to the changes we experience every day in our own lives. Many factors can influence change in software, from the changing needs of the users to an increase in the number of software package users. Whatever the reason may be, you will want the software you write to be able to handle such changes with as little pain as possible. This is where the SOLID principles of object-oriented design come into play. As a part of the solid series, the principle I’d like to take a look at in this post is the Dependency Inversion Principle (DIP).

Overview

The meaning of the Dependency Inversion Principle can be found in its name. It is about changing the direction of dependencies within your code so that they flow in a direction that decouples the more volatile code from the more stable code (which is a topic we will explore in a later post). Highly coupled code is code that cannot be easily changed without requiring modifications to the code it is coupled with. By decoupling one unit of code from another, you can make changes in the decoupled code without impacting the current unit of code.

Example Without DIP

I find that the best way to understand something is through the use of examples. So, to explain the Dependency Inversion Principle (DIP), let’s consider the example of a notification service. In the following section, we will explore UML diagrams and accompanying code that demonstrate the implementation without adhering to the DIP. By examining this initial approach, we can observe the tight coupling between components. However, as we proceed to examine the same code implemented with the DIP in mind, the benefits of decoupling become evident. It becomes apparent how changes made in one part of the codebase do not impact the previously coupled unit of code, showcasing the advantages of adhering to the DIP.

UML

In this UML class diagram, we have three classes represented, and the arrows between them depict the dependencies between these classes. Of particular interest in our discussion is the dependency line from EmailNotificationService to UserService. This line signifies a tight coupling between UserService and EmailNotificationService, meaning that any changes related to how a user is notified when they register a new account (e.g., switching from email to another notification method) would require modifications not only in the implementation of the new notification service but also within the UserService class. This coupling becomes more apparent when we examine the following code example, which illustrates the implications of such interdependencies.

Source Code

To maintain simplicity, I have implemented each class within the same TypeScript file located at ‘/src/index.ts‘. Additionally, I have written the code that calls the UserController and logs the resulting output to the terminal in ‘/index.js‘. Below are the source code snippets for these two files, respectively:

without.ts

class User {
  constructor(
    public name: string,
    public email: string,
    public phone: string
  ) {}
}

class EmailNotificationService {
  sendEmail(user: User, message: string): string {
    // Logic to send email notification
    return `Email notification sent successfully to ${user.email}.`;
  }
}

class UserService {
  constructor(private notificationService: EmailNotificationService) {}

  registerUser(user: User): string {
    // Logic to register the user
    // ...
    // Notification service call
    const notificationResult = this.notificationService.sendEmail(
      user,
      `Account for ${user.name} created successfully.`
    );

    console.log(notificationResult);

    if (
      notificationResult ===
      `Email notification sent successfully to ${user.email}.`
    ) {
      return `Account created successfully for ${user.name}.`;
    } else {
      return `Failed to send email notification for ${user.name}.`;
    }
  }
}

export class UserController {
  private userService: UserService;

  constructor() {
    const emailNotificationService = new EmailNotificationService();
    this.userService = new UserService(emailNotificationService);
  }

  createUser(name: string, email: string, phone: string): string {
    const user = new User(name, email, phone);
    return this.userService.registerUser(user);
  }
}

withoutExample.ts

import { UserController } from "./without";

// Usage example
const controller = new UserController();
const result = controller.createUser(
  "John Doe",
  "[email protected]",
  "1234567890"
);
console.log(result);

Observations

  1. In the UserService class, it is worth noting that the constructor is initialized with an instance of EmailNotificationService. This tightly couples the UserService with the EmailNotificationService. If the requirements change and notifications need to be sent via SMS, modifications to this code would be necessary.
  2. Additionally, in the UserService.registerUser() method, the sendEmail method of EmailNotificationService is called. This direct dependency on the EmailNotificationService would also require changes if a different type of notification service were to be introduced.
  3. Furthermore, in the UserController, there is a reference to EmailNotificationService where the service is instantiated and passed as a parameter to the UserService constructor. This would also need to be modified if the requirements were to change and an SMSNotificationService were to be used instead of an email service.

Example with DIP

Now we are going to take a look at what this would look like when the solution is redesigned using the Dependency Inversion Principle.

UML

A key aspect of successfully applying the Dependency Inversion Principle is transforming concrete classes, such as UserService and EmailNotificationService, to depend on abstractions. In our case, these abstractions are IUserService and INotificationService. The inversion occurs when the abstractions themselves depend on one another, with IUserService depending on INotificationService. This inversion of dependencies is where the “inversion” happens in the Dependency Inversion Principle.

By inverting the dependencies, the concrete classes extend their respective interfaces (IUserService and INotificationService) instead of depending directly on one another. Consequently, if there is a need to introduce a new type of notification service, the specific implementation of UserServiceImpl remains unchanged. This decoupling allows for flexibility in extending or modifying the system without modifying existing code.

The following source code provides a clearer demonstration of these principles in action.

with.ts

class User {
  constructor(
    public name: string,
    public email: string,
    public phone: string
  ) {}
}

export interface INotificationService {
  notify(user: User, message: string): string;
}

export class EmailNotificationService implements INotificationService {
  notify(user: User, message: string): string {
    // Logic to send email notification
    console.log(`Email notification sent successfully to ${user.email}.`);
    return "Notification sent successfully.";
  }
}

export class SMSNotificationService implements INotificationService {
  notify(user: User, message: string): string {
    // Logic to send email notification
    console.log(`SMS notification sent successfully to ${user.phone}.`);
    return "Notification sent successfully.";
  }
}

export interface IUserService {
  registerUser(user: User): string;
}

export class UserServiceImpl implements IUserService {
  constructor(private notificationService: INotificationService) {}

  registerUser(user: User): string {
    // Logic to register the user
    // ...
    // Notification service call
    const notificationResult = this.notificationService.notify(
      user,
      "Account created successfully."
    );

    if (notificationResult === "Notification sent successfully.") {
      return `Account created successfully for ${user.name}.`;
    } else {
      return `Failed to send notification for ${user.name}.`;
    }
  }
}

export class UserController {
  private userService: IUserService;

  constructor(userService: IUserService) {
    this.userService = userService;
  }

  createUser(name: string, email: string, phone: string): string {
    const user = new User(name, email, phone);
    return this.userService.registerUser(user);
  }
}

withExample.ts

// Import the necessary classes and interfaces from the correct path
import {
  UserController,
  UserServiceImpl,
  IUserService,
  EmailNotificationService,
  SMSNotificationService,
  INotificationService,
} from "./with";

console.log("");
console.log("*************** Uses Email Notification ***************");
const emailNotificationService: INotificationService =
  new EmailNotificationService();
const userService: IUserService = new UserServiceImpl(emailNotificationService);
const userController = new UserController(userService);

// Creating a user
const result = userController.createUser(
  "John Doe",
  "[email protected]",
  "123456789"
);

console.log(result);

console.log("");
console.log("*************** Uses SMS Notification ***************");
const smsNotificationService: INotificationService =
  new SMSNotificationService();
const userService2: IUserService = new UserServiceImpl(smsNotificationService);
const userController2 = new UserController(userService2);

// Creating a user
const result2 = userController2.createUser(
  "Jane Doe",
  "[email protected]",
  "123456789"
);
console.log(result2);

With Example Output

Conclusion

In conclusion, we have explored the Dependency Inversion Principle (DIP) and its significance in software design. By applying the DIP, we can achieve loose coupling, flexibility, and modularity in our codebase. Through the use of abstractions and dependency injection, we invert the dependencies between modules, allowing for independent development, easy extensibility, and improved testability.

In the example of a notification service, we observed how the DIP promotes decoupling between the UserService and EmailNotificationService. By depending on abstractions (IUserService and INotificationService), we avoid direct dependencies on concrete implementations. This decoupling enables us to introduce new notification services without modifying existing code, demonstrating the power of the DIP in facilitating change and maintaining a robust codebase.

To further explore the concepts discussed and examine the corresponding source code, you can access the code examples at https://github.com/reformit/DependencyInversionPrinciple. Delve into the codebase, experiment with different implementations, and witness firsthand how the DIP enhances flexibility and maintainability.

By embracing the Dependency Inversion Principle, we empower our code to adapt to evolving requirements, enable modular development, and build robust software systems. Incorporating these principles in our design practices contributes to more maintainable, extensible, and scalable applications.