Dependency Inversion (SOLID) Principle

Dependency Inversion (SOLID) Principle

The article discusses what the Dependency Inversion Principle is and why one should consider using it

TLDR: The Dependency Inversion Principle states that

  • High-level modules should not depend on low-level modules. Both should rely on abstractions

  • Abstractions should not depend on details. Details should depends on abstractions

Understanding Dependency Inversion

The power of the Dependency Inversion principle lies in the abstraction of classes/objects and loosely coupling them. Let's take a look at the what the Dependency Inversion (DI) Principle states:

  • High-level modules should not depend on low-level modules. Both should rely on abstractions

  • Abstractions should not depend on details. Details should depends on abstractions

To better understand this, let's use a simple example of a Notification Manager that is responsible for sending an email notification to it's clients.

Note: The use of "clients" and "manager" does not hold any semantic value. It is used here simply to denote some arbitrary clients that receive notifications.

class NotificationManager {
  constructor(private _emailNotificationService: EmailNotificationService) {
  }

  updateClients() {
    // Some code will react to an event that requires all the clients 
    // receive an notification
    this._emailNotificationService.send(clients)
  }

  ...
}
class EmailNotificationService {
  send(clients) {
    // Send an email update
    console.log('sending email notification')
  }

  ...
}

In this example, a naive implementation uses a NotificationManager class that is responsible for updating its clients. It depends on an EmailNotificationService to update the clients via an email notification strategy.

The high-level module (NotificationManager) unfortunately depends on a low-level module (EmailNotificationService) which violates the first part of the DI principle. It also violates the second part since we depend on a concrete class (EmailNotificationService).

So what is wrong in that? Like the common saying goes, "if it ain't broke, don't fix it". For now, this code works fine, however, like every software, requirements change.

Let's assume that tomorrow we have a new requirement - update the clients via SMS notification since users rarely check their emails.

Now, we need to create a new class SMSNotificationService to handle updating clients via SMS strategy. We will also need to update our NotificationManager's constructor signature to accept a SMSNotificationService.

The tight coupling with dependencies creates code that is fragile and difficult to test. Any changes to a low-level module impacts the high-level module since dependencies are transitive.

We can improve this program by applying the Dependency Inversion Principle!

Benefits of using Dependency Inversion

Let us try to improve the design of our program by decoupling the modules. We can introduce an interface that our low-level module can implement.

interface NotificationService {
  send(): void;
}

class NotificationManager {
  // Rely on an abstraction "NotificationService"
  constructor(private _notificationService: NotificationService) {

  }

  updateClients() {
    this._notificationService.send()
  }

  ...
}


class EmailNotificationService implements NotificationService {
  send() {
    console.log('sending notification')
  }

  ...
}

Instead of relying on a concrete class, our NotificationManager can depend on an abstraction NotificationService. This has a few benefits:

  • modules are decoupled from each other

  • modules depends on abstractions, rather than concrete implementations

  • any class that implements the interface can be used as a notification client - SMS, Email, IM etc. i.e. the manager does not care who the client is

Here is the secret - the name Dependency Inversion comes from inverting the ownership of the interface. We often think the class that implements an interface, owns it. But with the DI principle, the client/class owns the interface, and its dependencies implement them.

To address our new requirement, we can now simply create a SMSNotificationService class that implements our NotificationService interface. Our manager class remains unchanged since we are no longer coupled to a concrete class.

class SMSNotificationService implements NotificationService {
  send() {
    console.log('sending notification')
  }
}

Common pitfall and challenges

  • Over-Engineering: introducing too many abstractions in your codebase can make it difficult to navigate and introduce redundant complexities especially in a complex large-scale application.

  • Interface Pollution: adding too many interfaces solely for DI principle can lead to interface pollution which adds clutter to the codebase and complexity to maintenance.

  • Increased Indirection: the additional layer of abstraction can often lead to code that is difficult to debug and trace, especially to developers that unfamiliar with the codebase.

  • Resistance to Refactoring: Recognizing abstraction boundaries, and refactoring the codebase to implement these abstraction adds overhead to the development time of an application. Teams may be reluctant to take on this added risk and increased overhead.

Conclusion

It is imperative to understand that DI Principle is not a quick-fix solution but rather a strategic design principle that should be used judiciously where it offers the most significant value.

While abstraction adds flexibility and maintainability, excessive abstraction can lead to unnecessary complexity. Thus, it is crucial to strike the right balance, ensuring that the abstractions introduced serve a clear purpose and add tangible value to the architecture.

When building a new project, always start with a naive implementation. Once the MVP is established, the team can then conduct a thorough analysis of the codebase, identifying areas where the application of DI Principle can bring about improvements.

In simpler terms, embracing the Dependency Inversion Principle isn't just about following rules—it's about being flexible and adaptable. By using DIP wisely and continuously improving our code, we create software that is easy to manage and can quickly adapt to changes. This mindset helps us stay effective in the ever-changing world of software development

References