DEV Community

Cover image for Dependency Injection: The Secret to Clean Code and Modular Design
Kinanee Samson
Kinanee Samson

Posted on • Updated on

Dependency Injection: The Secret to Clean Code and Modular Design

Dependency Injection (DI) and Inversion of Control(IOC) are two concepts that are often used interchangeably but there's a fine line between the both of them. IOC is a software design pattern that allows us to shift the control flow of our program to the framework, Runtime, or Container. In a normal application where IOC is not employed the developer is usually responsible for managing the flow of the code but with IOC we delegate that responsibility to the Framework, Runtime, or Container.

Dependency Injection is a design pattern where dependencies are provided to a class externally instead of creating them within the class itself, this promotes loose coupling and improved testability. Dependency Injection is one of the methods of achieving Inversion Of Control.

Now we've established what they both are and the distinction between the two of them, in today's post we'll cover the following

  • Inversion of Control
  • How can we implement DI
  • Benefits of IOC and DI
  • Drawbacks of using IOC & DI

Inversion of control

We have already established what IOC is you should know that DI is not the only way IOC can be achieved, Event-Driven systems also employ IOC. So you've been using it all this time when working with Javascript. The Event Loop is provided by the Runtime while you write code that will be called by the Event loop when the particular Event occurs, you can see that we've delegated that part of the program to the "Framework" which in this case is the Runtime.

How Can we Implement DI

Let's say we have three classes, the first AuthService is a class that serves as an authentication service. We also have two more classes; a NotificationService and an EmailService. Anytime the user tries to use any of the methods defined on the AuthService we need to send them an email and/or a notification.

class AuthenticationService {
  login(username: string, password: string): boolean {
    // ... authentication logic ...

    if (isAuthenticated) {
      const notificationService = new PushNotificationService();
      notificationService.sendNotification("Login successful!");

      const emailService = new EmailService();
      emailService.sendEmail(
        username,
        "Login Alert",
        "Your account has been logged in."
      );
    }

    return isAuthenticated;
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code snippet we have here you can observe that the login function is responsible for instantiating the PushNotificationService and the EmailService. This is what we describe as tight coupling because a change in either the notification service or the email service might break the login function. This is not ideal we can instead remove the instantiation of the email service or the notification service outside the Authentication service and the services will become a dependency on the authentication service.

class PushNotificationService {
  sendNotification(message: string): void {
    // Send a push notification using a push notification library
    console.log("Sending push notification:", message);
  }
}

class EmailService {
  sendEmail(recipient: string, subject: string, body: string): void {
    // Send an email using an email library
    console.log(
      "Sending email to:",
      recipient,
      "Subject:",
      subject,
      "Body:",
      body
    );
  }
}

class AuthenticationService {
  private notificationService: NotificationService;
  private emailService: EmailService;

  constructor(
    notificationService: NotificationService,
    emailService: EmailService
  ) {
    this.notificationService = notificationService;
    this.emailService = emailService;
  }

  login(username: string, password: string): boolean {
    // ... authentication logic ...

    if (isAuthenticated) {
      this.notificationService.sendNotification("Login successful!");
      this.emailService.sendEmail(
        username,
        "Login Alert",
        "Your account has been logged in."
      );
    }

    return isAuthenticated;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we have loosely coupled the notification and the email Service to the Authentication Service the Authentication Service accepts notification and email services as dependencies. Which are injected, promoting flexibility and testability. Now any changes to the implementation of the Notification/Email service will not affect the Authentication Service, different notification/email providers can be easily swapped without modifying AuthenticationService. This approach makes it easier to unit test AuthenticationService in isolation. Let's consider another instance where we can use DI to improve the quality of our code.

Let's say we have a billing Service where we bill a client, then we send them a notification based on the response from billing the user. Instead of tightly coupling the Notification and the email service to the Billing Service as a newbie would.

class BillingService {
  billCustomer(customerId: number, amount: number): void {
    // ... billing logic ...
    const billingResponse = processBilling(customerId, amount); // Assuming a function for billing logic
    const notificationService = new NotificationService();
    const emailService = new EmailService();

    if (billingResponse.success) {
      notificationService.sendNotification("Billing successful!");
      emailService.sendEmail(
        customerId,
        "Billing Confirmation",
        "Your payment has been processed successfully."
      );
    } else {
      notificationService.sendNotification("Billing failed. Please try again.");
      emailService.sendEmail(
        customerId,
        "Billing Error",
        "There was an error processing your payment."
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We can instead decouple the Notification/Email service from the billing service for modularity, better readability, and easy testing.

class BillingService {
  constructor(
    private notificationService: NotificationService,
    private emailService: EmailService
  ) {
    this.notificationService = notificationService;
    this.emailService = emailService;
  }

  billCustomer(customerId: number, amount: number): void {
    // ... billing logic ...
    const billingResponse = processBilling(customerId, amount); // Assuming a function for billing logic

    if (billingResponse.success) {
      this.notificationService.sendNotification("Billing successful!");
      this.emailService.sendEmail(
        customerId,
        "Billing Confirmation",
        "Your payment has been processed successfully."
      );
    } else {
      this.notificationService.sendNotification(
        "Billing failed. Please try again."
      );
      this.emailService.sendEmail(
        customerId,
        "Billing Error",
        "There was an error processing your payment."
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Benefits Of DI & IOC

So you might ask yourself what is the benefit of this approach?

  • Using DI allows our code to follow two important SOLID design principles, that is the Interface Segregation Principle which states that our classes should not rely on composing multiple interfaces together as opposed to relying on one big Interface. It also follows the Single responsibility principle which states that the classes and functions should only be responsible for doing one thing thus they should only have one reason to change. This approach makes our code much more modular, reusable, and easy to update.
  • The classes in our code will rely on interfaces or abstractions, not concrete implementations thus Changes in one class will not ripple through the entire system and break production, making our code easier to maintain and evolve.
  • Dependencies can be mocked or stubbed for unit testing for improved Testability, we can also test components independently, simplifying test case creation and execution.
  • Components can be easily swapped or replaced with different implementations, making our application incredibly Flexible and reusable. This approach promotes code reuse across different parts of the application. Enabling the application to adapt to changing requirements without major code rewrites.
  • Dependencies can be configured externally through files or frameworks without any need to modify code to change dependencies. Making our code more adaptable to different environments and deployments.
  • Code becomes more concise and focused on core functionality, reducing boilerplate code for managing dependencies which improves code readability and maintainability.

Drawbacks of using IOC & DI

While DI and IoC offer significant benefits, they also have some potential drawbacks to consider:

  • Excessive dependence on configuration files can make code less readable and harder to debug. It also requires careful management of configuration files to avoid errors and inconsistencies.
  • Resolving dependencies at runtime can incur a slight performance cost, especially in high-performance applications. Optimizations might be needed for performance-critical sections of the code.
  • Tracing issues through multiple layers of abstraction can be more challenging than in tightly coupled systems may require specialized debugging tools or techniques.

That's going to be it for this piece, I hope you found this useful and learned something new, what are your thoughts on implementing IOC using Dependency Injection, do you think the benefits of this approach outweigh the drawbacks? Do you personally use Dependency Injection and if yes, do you manage your dependency injection manually or do you use a dependency injection container? What are your thoughts on this post? Let me know all these and more using the comment section below.

Top comments (0)