Welcome to the fifth (and last) post of our series dedicated to the use of SOLID principles. This one is dedicated to the Dependency Inversion Principle. So far, we have covered all the other principles:
- Single responsibility principle
- Open / closed principle
- Liskov substitution principle
- Interface segregation principle
- Dependency inversion principle
The fifth SOLID principle: Dependency Inversion Principle
“High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.”
This principle is one of my favorites because it puts it so clearly that you should worry a lot about decoupling.
Notes on tight coupling
Tight coupling is bad - and we’ve already talked about this. It steals you the possibility of freely replacing or changing components without the need to change other components. It makes your despair waiting for a build to end because you need to re-compile everything all over again. It makes you want to shoot your foot when you want to write a unit test and need to start a bunch of classes that have nothing to do with that same test. It makes your time feel useless when you’re trying to use some feature of a module that you developed before in a new project, but end up spending more time removing dependencies than you would need to re-write the feature all over again.
I hope this is convincing enough to make you think twice about tightly coupled systems.
Why does this keep happening to me?
I would dare to say that designing tightly coupled systems is a more of a consequence that comes from our mindset when developing software.
As I mentioned before, there is a natural tendency for us programmers to write code using a (normally short-sighted) top-down approach. We essentially go from designing the higher level logic and modules to break it apart into pieces that implement our low-level designs. One common mistake that we do is that when something more specific is needed at a lower layer object (say some argument to a function), we tend to add it to the object defining the higher-level logic layer. So we start mixing logic with implementation, and end up with a tightly coupled system.
Introducing tight coupling also happens very frequently when we need to use some 3rd party library or dependency. The natural tendency for us is to use the library directly in our code, but this can create a tangled web of dependencies amidst our code. What would happen if the developers of that library suddenly decided to change the API of that particular library? What if after all that hard work you just found a new library that does the same job but is much more efficient? Here’s a well spent afternoon changing every call to that lib that’s in your codebase…
So what can you do about this?
From dependency injection to the dependency inversion principle
One thing that helps when designing loosely coupled systems is to use dependency injection. In this context, a dependency is a simple interface that can abstract the real dependencies between our modules. Then you can inject (i.e. pass onto) the clients with the interface, allowing them to use any functions they require from the dependency. This injection is normally done via the constructor of the client, one of its fields or arguments to the client’s methods.
Wrappers are a clear examples of this. Suppose you need some networking library in your project. Rather than using this library directly, you should create a wrapper around the library's functionalities and pass the interface to the clients. This would decouple every client from the external library and allow you to switch between implementations of the networking interface. You can checkout a nice example here.
Going back to our higher-level logic vs. low-level implementation dichotomy, you can see that if you use dependency injection, you can invert (which is not really inverting, but rather removing…) the direct dependencies between the modules. Instead, you have a dependency of an abstract layer that does not depend on the peculiarities of the low level implementation. Yes, dependency injection and the dependency inversion principle are not the same thing. It’s more like they have a thing - they just go nicely together.
To wrap this post up (and this series!) there is only one advice I can give you: always start by defining your higher-level logics, then go immediately for potential dependencies and abstract them. Wrappers are one of the most beautiful things ever designed so use them frequently. Then, when everything is beautiful decoupled, just sit and relax: there are only a few lines of code missing, and they are the easier ones!