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.
Clients shouldn’t be forced to depend upon interfaces that they don’t use.
Robert C. Martin
Classes and methods we write should meet the client’s business needs.
If a class implements an interface, it should implement all of its methods. There shouldn’t be unused methods in the class. Additionally, there shouldn’t be methods that do nothing (are empty) or cause errors when used.
If a class implements an interface, it must implement all its methods. Therefore, it’s better to create more small interfaces and implement them as needed than to create overly large interfaces with too many methods that won’t be used.
Let’s consider an example interface and 3 classes implementing it:
public class Car implements Vehicle{
@Override
public void drive() {
System.out.println("Car is driving.");
}
@Override
public void floatOn() {
System.err.println("Cars don't float!");
}
@Override
public void fly() {
System.err.println("Cars don't fly!");
}
}
public class Ship implements Vehicle {
@Override
public void drive() {
System.err.println("Ships don't drive!");
}
@Override
public void floatOn() {
System.out.println("Ship is floating.");
}
@Override
public void fly() {
System.err.println("Ships don't fly!");
}
}
public class Plane implements Vehicle{
@Override
public void drive() {
System.out.println("Plane is driving.");
}
@Override
public void floatOn() {
System.out.println("Plane is floating.");
}
@Override
public void fly() {
System.out.println("Plane is flying.");
}
}
Let’s create a list of our vehicles and call each method on them:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Runner {
public static void main (String[] args) {
List<Vehicle> vehicles = new ArrayList<>(Arrays.asList(new Car(), new Ship(), new Plane()));
for (Vehicle vehicle : vehicles) {
vehicle.drive();
vehicle.floatOn();
vehicle.fly();
}
}
}
After running the example, we get:
Cars don't float!
Cars don't fly!
Ships don't drive!
Ships don't fly!
Car is driving.
Ship is floating.
Plane is driving.
Plane is floating.
Plane is flying.
All 3classes implement the interface, so they must implement all methods. But not all classes use all methods.
Improved implementation
Let’s break this interface into 3 separate interfaces.
public interface Drivable {
void drive();
}
public interface Floating {
void floatOn();
}
public interface Flying {
void fly();
}
and implement them only in the appropriate classes:
public class Car implements Drivable {
@Override
public void drive() {
System.out.println("Car is driving.");
}
}
public class Ship implements Floating {
@Override
public void floatOn() {
System.out.println("Ship is floating.");
}
}
public class Plane implements Drivable, Floating, Flying {
@Override
public void drive() {
System.out.println("Plane is driving.");
}
@Override
public void floatOn() {
System.out.println("Plane is floating.");
}
@Override
public void fly() {
System.out.println("Plane is flying.");
}
}
Let’s call the available methods on them again:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Runner {
public static void main (String[] args) {
Car car = new Car();
Ship ship = new Ship();
Plane plane = new Plane();
car.drive();
ship.floatOn();
plane.drive();
plane.floatOn();
plane.fly();
}
}
After running the program this time, we get:
Car is driving.
Ship is floating.
Plane is driving.
Plane is floating.
Plane is flying.
There are no more unused or unnecessary methods in any of the classes.
Why to use the Integration segregation principle
Code written according to the interface segregation principle is easier to develop. When creating a new class, only the necessary methods need to be implemented.
Interfaces written according to the interface segregation principle are easier to reuse. With fewer specific methods, they are more universal.
Subclasses should satisfy the expectations of clients accessing subclass objects through references of supercalss type, not just as regards syntactic safety (such as absence of “method-not-found” errors) but also as regards behavioral correctness.
Barbara Liskov
When inheritance is used, it should be possible to replace an object of the base class with an object of a subclass without breaking the program (either technically or from a business perspective). In other words, if a class Child inherits from a class Parent, then an object of the Child class should be usable in any method that works with a Parent reference or pointer.
This means the subclass must implement all the methods of the base class. The subclass should add new features or extend the base class, but it should not change or remove any important behavior.
Let’s look at an example. We have a base class IRobotRoomba, and two classes (specific models) that inherit from it:
public class IRobotRoomba {
public void vacuum () {
System.out.println("IRobot Roomba is vacuuming");
}
public void mop () throws Exception {
System.out.println("IRobot Roomba is mopping");
}
}
public class Plus405Combo extends IRobotRoomba{
final int height = 106;
@Override
public void vacuum() {
System.out.println("IRobot Roomba Plus 405 Combo is vacuuming");
}
@Override
public void mop() {
System.out.println("IRobot Roomba Plus 405 Combo is mopping");
}
}
public class I5 extends IRobotRoomba {
final int height = 92;
@Override
public void vacuum() {
System.out.println("IRobot Roomba I5 is vacuuming");
}
@Override
public void mop() throws Exception {
// THIS MODEL CAN'T MOP throw new Exception();
}
}
Now, we have a class SmartHomeSystem that uses the robot to vacuum and mop:
public class SmartHomeSystem {
public void vacuumHome (IRobotRoomba iRobot) {
iRobot.vacuum();
}
public void mopHome (IRobotRoomba iRobot) {
try {
iRobot.mop();
} catch (Exception e) {
System.err.println("An error occured!");
}
}
}
Let’s create a list of robots:
iRobotRoomba,
iRobotRoomba Plus 405 Combo,
iRobotRoomba i5
and use them to vacuum and mop the house:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Runner {
public static void main (String[] args) {
SmartHomeSystem system = new SmartHomeSystem();
List<IRobotRoomba> robots = new ArrayList<>(Arrays.asList(new IRobotRoomba(), new Plus405Combo(), new I5()));
for (IRobotRoomba robot : robots) {
system.vacuumHome(robot);
system.mopHome(robot);
};
}
}
Program output:
IRobot Roomba is vacuuming
IRobot Roomba is mopping
IRobot Roomba Plus 405 Combo is vacuuming
IRobot Roomba Plus 405 Combo is mopping
IRobot Roomba I5 is vacuuming
An error occured!
The I5 model cannot mop. Replacing the base class with I5 breaks the program – it breaks the Liskov substitution principle.
Improved implementation
Let’s move the mopping function into a separate interface:
public class IRobotRoomba {
public void vacuum () {
System.out.println("IRobot Roomba is vacuuming");
}
}
public interface moppingDevice {
void mop ();
}
and implement it in iRobot Roomba Plus 405 Combo:
public class Plus405Combo extends IRobotRoomba implements MoppingDevice {
final int height = 106;
@Override
public void vacuum() {
System.out.println("IRobot Roomba Plus 405 Combo is vacuuming");
}
@Override
public void mop() {
System.out.println("IRobot Roomba Plus 405 Combo is mopping");
}
}
Class I5 only needs to implement 1 method now:
public class I5 extends IRobotRoomba {
final int height = 92;
@Override
public void vacuum() {
System.out.println("IRobot Roomba I5 is vacuuming");
}
}
We also update the SmartHomeSystem class to use objects implementing MoppingDevice interface for mopping:
public class SmartHomeSystem {
public void vacuumHome(IRobotRoomba iRobot) {
iRobot.vacuum();
}
public void mopHome(MoppingDevice iRobot) {
iRobot.mop();
}
}
This time we need to create 2 lists:
robots contain all IRobotRoomba devices (vacuum cleaners),
but moppingDevices contains only devices implementing moppingDevice interface (only iRobot Roomba Plus 405 Combo at the moment)
and again use them to vacuum and mop the house:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Runner {
public static void main (String[] args) {
SmartHomeSystem system = new SmartHomeSystem();
List<IRobotRoomba> robots = new ArrayList<>(Arrays.asList(new IRobotRoomba(), new Plus405Combo(), new I5()));
List<MoppingDevice> moppingDevices = new ArrayList<>(Arrays.asList(new Plus405Combo()));
for (IRobotRoomba robot : robots) {
system.vacuumHome(robot);
};
for (MoppingDevice moppingDevice : moppingDevices) {
system.mopHome(moppingDevice);
};
}
}
Program output:
IRobot Roomba is vacuuming
IRobot Roomba Plus 405 Combo is vacuuming
IRobot Roomba I5 is vacuuming
IRobot Roomba Plus 405 Combo is mopping
We moved the problematic method into a separate interface. Only robots that can mop implement it. This way, we can safely replace base class objects with subclass objects.
Additional rules from the Liskov substitution principle
There are 2more rules that come from the Liskov substitution principle:
Subclasses shouldn’t strengthen preconditions. Preconditions can be weakaned – it won’t break a program. If the preconditions in the base class are harder to satisfy, the program will still have to satisfy them..
Subclasses shouldn‘t weaken postconditions. Postconditions can be strengthen – it won’t break a program. Można wzmacniać warunki końcowe ponieważ to nie zepsuje programu.If the postconditions in the base class are easier to satisfy, the program will be prepared to handle them.
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
Mayer Bertrand
Program behavior (functions, classes, modules, etc.) should be changed without modifying existing code. Instead, new code should be added to extend the program.
The code should be written in a way that makes it easy to add new features without changing already working parts. The goal is to grow the program by adding new elements, not by editing existing ones.
To follow this rule, it’s important to use abstractions.
Let’s say we have two classes, each representing a different type of customer:
public class RegularCustomer {
final double discountLevel = 0.95;
// method returns unit price with discount
public double calculateDiscountedPrice (double basePrice) {
return basePrice * discountLevel ;
}
}
public class PremiumCustomer {
final double discountLevel = 0.8;
// method returns unit price with discount
public double calculateDiscountedPrice (double basePrice) {
return basePrice * discountLevel ;
}
}
We also have the PriceCalculator class. It calculates the final price depending on the customer type and quantity:
public class PriceCalculator {
// method returns final price depending on the type of base price, customer type and quantity
public double calculateFinalPrice (double basePrice, int quantity, String customerType) {
switch (customerType) {
case "REGULAR":
RegularCustomer regularCustomer = new RegularCustomer();
return quantity * regularCustomer.calculateDiscountedPrice(basePrice);
case "PREMIUM":
PremiumCustomer premiumCustomer = new PremiumCustomer();
return quantity * premiumCustomer.calculateDiscountedPrice(basePrice);
default:
return quantity * basePrice;
}
}
}
public class Runner {
public static void main (String [] args) {
System.out.println(PriceCalculator.calculateFinalPrice(100, 100, "PREMIUM"));
}
This code works, but there is a problem:
to add a new type of customer, one must change the code in the PriceCalculator class:
public class VipCustomer {
final double discountLevel = 0.5;
// method returns unit price with discount
public double calculateDiscountedPrice (double basePrice) {
return basePrice * discountLevel ;
}
}
public class PriceCalculator {
// method returns final price depending on the type of base price, customer type and quantity
public double calculateFinalPrice (double basePrice, int quantity, String customerType) {
switch (customerType) {
case "REGULAR":
RegularCustomer regularCustomer = new RegularCustomer();
return quantity * regularCustomer.calculateDiscountedPrice(basePrice);
case "PREMIUM":
PremiumCustomer premiumCustomer = new PremiumCustomer();
return quantity * premiumCustomer.calculateDiscountedPrice(basePrice);
case "VIP":
VipCustomer vipCustomer = new VipCustomer();
return quantity * vipCustomer.calculateDiscountedPrice(basePrice);
default:
return quantity * basePrice;
}
}
}
We are breaking the Open closed principle — we are modifying working code just to add something new.
Improved implementation
Let’s add an interface.
public interface Customer {
public double calculateDiscountedPrice (double basePrice) {
return basePrice * discountLevel ;
}
}
and implement it in all cusotmer classes:
public class RegularCustomer implements Customer{
final double discountLevel = 0.95;
@Override
public double calculateDiscountedPrice (double basePrice) {
return basePrice * discountLevel ;
}
}
public class PremiumCustomer implements Customer{
final double discountLevel = 0.8;
@Override
public double calculateDiscountedPrice (double basePrice) {
return basePrice * discountLevel ;
}
}
public class VipCustomer implements Customer{
final double discountLevel = 0.5;
@Override
public double calculateDiscountedPrice (double basePrice) {
return basePrice * discountLevel ;
}
}
Now the PriceCalculator class is much simpler:
public class PriceCalculator {
public double calculateFinalPrice (double basePrice, int quantity, Customer customer) {
return quantity * customer.calculateDiscountedPrice(basePrice);
}
}
We don’t need to check the customer type anymore. We just pass in the correct object, and Java calls the correct method.
public class Runner {
public static void main (String [] args) {
System.out.println(PriceCalculator.calculateFinalPrice(100, 100, new RegularCustomer()));
}
Why to use the Open-closed principle
Code written with the Open-closed principle is easier to grow and change. By using an interface that contains common behaviors , we can:
Add a new customer type by just creating a new class that implements the interface and without need to touch the old code, which is already tested and working.
Gather together the things that change for the same reasons. Separate those things that change for different reasons.
Robert C. Martin
Every function, class or module should have one responsibility and that responsibility should be encapsulated in it.
The code of a program should be organized into classes. Each class should have one clear goal. A class should contain methods that do similar things. This means that similar functions (doing similar tasks) should be grouped into one class.
Let’s look at an example. Below is a class that generates a report:
public class ReportGenerator {
// method prints the report
public void printReport (String reportId) {
System.out.println(generateReport(reportId));
}
// method generates the report based on the given ID
private String generateReport (String reportId) {
// some logic to generate the report
return wygenerowanyRaport;
}
}
public class Runner {
public static void main (String [] args) {
ReportGenerator generator = new ReportGenerator();
generator.printReport("123");
}
The class has two responsibilities. That means there are two possible reasons to change it:
The generateReport method is responsible for creating a report,
The printReport method is responsible for displaying a report
A class should have only one reason to change.
Robert C. Martin
Improved implementation
Let’s split this class into two separate classes.
The ReportGenerator class will now only be responsible for generating reports:
public class ReportGenerator {
// method generates the report based on the given ID
public String generateReport (String reportId) {
// some logic to generate the report
return generatedRaport;
}
}
And the ReportPrinter class will be responsible for printing reports:
public class ReportPrinter {
// method prints the report
public void printReport (String reportToPrint) {
System.out.println(reportToPrint);
}
}
public class Runner {
public static void main (String [] args) {
ReportGenerator generator = new ReportGenerator();
ReportPrinter printer = new ReportPrinter();
String generatedReport = generator.printReport("123");
printer.printReport(generatedReport);
}
Why to use the Single responsibility principle
Code written with the Single responsibility principle is easier to maintain. Functions, classes, and modules become smaller and more consistent. Is is less likely to introduce bugs when changing anything.
It’s also easier to reuse code. Functions, classes and modules are simpler, more readable, and less specific. The code is better organized and more modular.