1. High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces). 2. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
Robert C. Martin
In traditional architecture, high-level modules consist of lower-level modules. It is tight coupling. In such architecture, high-level modules depend on lower-level modules. Any change made in a lower-level module forces a change in the higher-level modules. This dependency complicates the reuse of high-level components.
The goal of the dependency inversion principle is to reduce these couplings (loose coupling). Both layers communicate through interfaces that provide the methods required by high-level modules – an additional layer of abstraction. This is often done using dependency injection.
Let’s consider an example situation. We have a class called Displayer (a low-level module) responsible for displaying on the screen:
public class Displayer {
public void out() {
System.out.println("Display on the screen");
}
}
The ComputerSystem class (a high-level module) consists of, among other things, an object of the Displayer class – it depends on it:
public class ComputerSystem {
private Displayer displayer;
public ComputerSystem() {
this.displayer = new Displayer();
}
public void out () {
this.displayer.out();
}
}
Let’s create a Printer class responsible for printing and add it to the ComputerSystem:
public class Printer{
public void out() {
System.out.println("Print on the printer");
}
}
public class ComputerSystem {
private Displayer displayer;
private Printer printer;
public ComputerSystem() {
this.displayer = new Displayer();
this.printer = new Printer();
}
public void out (String device) {
switch (device) {
case "SCREEN":
this.displayer.out();
break;
case "PRINTER":
this.printer.out();
break;
default:
break;
}
}
}
Now let’s create an object of the ComputerSystem class and call the out method:
public class Runner {
public static void main (String[] args) {
ComputerSystem system = new ComputerSystem();
system.out("PRINTER");
}
}
The above example works, but it has 3 flaws:
Adding each new device requires changing the existing code of the ComputerSystem class – breaking the open-closed principle,
The ComputerSystem class depends on the Displayer and Printer classes – breaking the dependency inversion principle,
The classes are tightly coupled; the ComputerSystem class cannot exist without Displayer and Printer.
Improved implementation
Let’s create an interface with the out method and implement it in the Displayer and Printer classes:
public interface Device {
public void out();
}
public class Displayer implements Device{
@Override
public void out() {
System.out.println("Display on the screen");
}
}
public class Printer implements Device{
@Override
public void out() {
System.out.println("Print on the printer");
}
}
Nothing really changed in the classes themselves. They previously contained the out method, and now they implement the out method from the interface.
In the constructor of the ComputerSystem class, add an object implementing the Device interface as a parameter – this can be either Displayer or Printer because both classes implement the interface. This is dependency injection:
public class ComputerSystem {
private Device device;
public ComputerSystem(Device device) {
this.device = device;
}
public void out() {
this.device.out();
}
}
When passing an object of the Displayer or Printer class to the constructor, that specific implementation of the interface is stored in the device field. The program will automatically recognize the class of the object stored in device and run the method from the appropriate class – polymorphism:
public class Runner {
public static void main (String[] args) {
Printer printer = new Printer();
ComputerSystem system = new ComputerSystem(printer);
system.out();
}
}
Why to use the dependency inversion principle
The dependency inversion principle makes it easier to expand the application. New lower-level modules just need to implement the intermediary interface to be passed as arguments.
The dependency inversion principle gives more control to the user. They do not have to rely on tight dependencies; instead, they can connect high-level and low-level modules using the intermediary interface.
Higher-level modules are more versatile because they are not dependent on lower-level modules.