SOLID, the Five Basic Principles of Object-oriented Programming and Design

SOLID is the set of five basic principles of object-oriented programming and design.

  • SRP: Single Responsibility Principle
  • OCP: Open-Closed Principle
  • LSP: Liskov Substitution Principle
  • ISP: Interface Segregation Principle
  • DIP: Dependency Inversion

Taking the first letters gives the name SOLID.

SRP: Single Responsibility Principle

  • A software design component(class, function, module, and so on) should have only one responsibility.
  • A well-designed program is generally affected less by changes caused by new requirements.
  • In other words, it means a program with high cohesion and low coupling.
  • If one class has too many functions, that is, too many responsibilities, functions inside the class are more likely to become strongly coupled. This increases maintenance cost, so responsibilities must be separated.
  • A class should perform one responsibility. If it performs several, split it.

Example Violating SRP

The code below is an example of work by employee role.

package com.devkuma.tutorial.solid.srp.bad;

public interface Employee {

    // Accounting team work
    void calculatePay();

    // HR team work
    void reportHours();

    // DB administrator work
    void save();
}

The three methods above each have responsibilities for different actors, so the code violates SRP.
In other words, code for different actors should be separated.

Solution to the SRP Violation

The solution to the example above is as follows.

Shared data class

package com.devkuma.tutorial.solid.srp.good;

// Shared data
public class Employee {
    private Integer id;
    private String name;
    private Integer salary;

    public Employee(Integer id, String name, Integer salary) {
        this.id = id;
        this.name = name;
        this.salary = salary;
    }
}

HR work class

package com.devkuma.tutorial.solid.srp.good;

// HR team work
public abstract class HourReporter {

    private Employee employee;

    public HourReporter(Employee employee) {
        this.employee = employee;
    }

    abstract void reportHours();
}

Accounting work class

package com.devkuma.tutorial.solid.srp.good;

// Accounting team work
public abstract class PayCalculator {

    private Employee employee;

    public PayCalculator(Employee employee) {
        this.employee = employee;
    }

    abstract void reportHours();
}

DB administrator work class

package com.devkuma.tutorial.solid.srp.good;

// DB administrator work
public abstract class EmployeeSaver {

    private Employee employee;

    public EmployeeSaver(Employee employee) {
        this.employee = employee;
    }

    // Used by the development team.
    abstract void save();
}

Collect shared data in one place and move functions for different actors into classes that fit each responsibility.

OCP: Open-Closed Principle

  • Design should allow features to be modified or added(Open) without changing existing code(Closed).
  • Software should be open for extension(features) and closed against surrounding changes. Direct code modification should not be necessary.

Example Violating OCP

The code below is an example program that manages employee information.

package com.devkuma.tutorial.solid.ocp.bad;

public class Employee {
    private String description;

    private String[] names;

    public Employee(String description, String[] names) {
        this.description = description;
        this.names = names;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }

    public void setNames(String[] names) {
        this.names = names;
    }

    public String[] getNames() {
        return names;
    }
}
package com.devkuma.tutorial.solid.ocp.bad;

public class EmployeePrinter {

    public void print(Employee employee) {
        System.out.println(employee.getDescription());
        for (String name : employee.getNames()) {
            System.out.println(name);
        }
    }
}
package com.devkuma.tutorial.solid.ocp.bad;

public class Main {

    public static void main(String[] args) {
        Employee employee = new Employee("Employee information", new String[]{"devkuma", "araikuma", "kimkc"});
        new EmployeePrinter().print(employee);
    }
}

In the example above, if the data structure of names in the Employee object changes, the implementation of EmployeePrinter must also change.

Solution to the OCP Violation

The solution to the example above is as follows.

package com.devkuma.tutorial.solid.ocp.good;

public class Employee {
    private String description;

    private String[] names;

    public Employee(String description, String[] names) {
        this.description = description;
        this.names = names;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }

    public void setNames(String[] names) {
        this.names = names;
    }

    public String[] getNames() {
        return names;
    }

    public void printNames() {
        for (String name : names) {
            System.out.println(name);
        }
    }
}
package com.devkuma.tutorial.solid.ocp.good;

public class EmployeePrinter {

    public void print(Employee employee) {
        System.out.println(employee.getDescription());
        employee.printNames();
    }
}

In this code, the Employee object has the method that iterates over names.
In EmployeePrinter, even if the Employee object changes, it can be extended without modification as long as the interface remains the same.

LSP: Liskov Substitution Principle

  • Child classes must be able to perform the behavior possible in their parent classes.
  • If a method uses a parent type as an argument, subtypes of that type must also operate correctly without problems.

LSP Example

The Animal below is the top-level class for inheritance.

package com.devkuma.tutorial.solid.lsp;

public class Animal {

    void run(Integer speed) {
        System.out.println("running at " + speed + " km/h");
    }
}

Example Following LSP

The Dog object below can be said to follow LSP.


package com.devkuma.tutorial.solid.lsp.good;

import com.devkuma.tutorial.solid.liskovsubstitution.Animal;

public class Dog extends Animal {

    void bark() {
        System.out.println("bow-wow");
    }

    void run(Integer speed) {
        System.out.println("running at " + speed + " km/h");
    }
}

Example Not Following LSP

The Sloth object below cannot replace Animal, so it can be said to violate LSP.

package com.devkuma.tutorial.solid.lsp.bad;

import com.devkuma.tutorial.solid.liskovsubstitution.Animal;

public class Sloth extends Animal {

    void run(Integer speed) throws Exception {
        throw new Exception("Sorry, I'm too lazy to run");
    }
}

ISP: Interface Segregation Principle

  • A class should not implement interfaces it does not use. Several specific interfaces are better than one general interface. This removes unnecessary dependencies.
  • It means a class should not be affected by functionality(interfaces) it does not use.
  • For example, in a Print interface, print and scan should be separated.

ISP Example

The Animal below is an interface for implementation.

package com.devkuma.tutorial.solid.isp.ex1;

public interface Animal {
    void run();

    void eat();

    void cry();
}

In the code below, the Dog class implements all methods of the Animal interface, so there is no problem.

package com.devkuma.tutorial.solid.isp.ex1.good;

import com.devkuma.tutorial.solid.isp.Animal;

public class Dog implements Animal {
    @Override
    public void run() {
        System.out.println("run");
    }

    @Override
    public void eat() {
        System.out.println("eat");
    }

    @Override
    public void cry() {
        System.out.println("cry");
    }
}

However, the Lizard class does nothing in the cry method, so it depends unnecessarily on the Animal interface and violates ISP.

package com.devkuma.tutorial.solid.isp.bad;

import com.devkuma.tutorial.solid.isp.ex1.Animal;

public class Lizard implements Animal {
    @Override
    public void run() {
        System.out.println("run");
    }

    @Override
    public void eat() {
        System.out.println("eat");
    }

    // There is no handling for the cry method. It depends unnecessarily on Animal.
    @Override
    public void cry() {
        // Don't call this method
    }
}

ISP Solution

The solution is to extract only the common parts and separate them into finer-grained interfaces, removing unnecessary dependencies as shown below.

The Animal below is the top-level interface for implementation.

package com.devkuma.tutorial.solid.isp.ex2;

public interface Animal {
    void run();

    void eat();
}

The Mammal interface inherits the Animal interface and adds a new cry method.

package com.devkuma.tutorial.solid.isp.ex2;

interface Mammal extends Animal {

    void cry();
}

The Reptile interface inherits the Animal interface and does not create additional methods.

package com.devkuma.tutorial.solid.isp.ex2;

interface Reptile extends Animal {
}

The Dog class implements the run and eat methods of the Mammal interface, and also implements cry.

package com.devkuma.tutorial.solid.isp.ex2;

public class Dog implements Mammal {
    @Override
    public void run() {
        System.out.println("run");
    }

    @Override
    public void eat() {
        System.out.println("eat");
    }

    @Override
    public void cry() {
        System.out.println("cry");
    }
}

The Lizard class implements the run and eat methods of the Reptile interface.

package com.devkuma.tutorial.solid.isp.ex2;

public class Lizard implements Reptile {
    @Override
    public void run() {
        System.out.println("run");
    }

    @Override
    public void eat() {
        System.out.println("eat");
    }
}

DIP: Dependency Inversion

  • When forming dependencies, depend on things that are harder to change rather than things that are easy to change.
  • High-level modules should not depend on low-level modules; both should depend on abstractions. This prevents changes in low-level modules from affecting high-level modules.
  • Abstractions(interfaces/abstract classes) should not depend on implementation details(classes); implementation details should depend on abstractions.

DIP Example

The HttpClient below is the upper-level abstract class for implementation.

package com.devkuma.tutorial.solid.dip;

abstract public class HttpClient {

    public String get(String arg) {
        return arg;
    }
}

In the code below, DataProvider(the high-level module) depends on CustomHTTPClient(the low-level module), so it violates DIP.

package com.devkuma.tutorial.solid.dip.good;

import com.devkuma.tutorial.solid.dip.HttpClient;

public class CustomHttpClient extends HttpClient {

}
package com.devkuma.tutorial.solid.dip.good;

import com.devkuma.tutorial.solid.dip.HttpClient;

public class DataProvider {

    public HttpClient httpClient;

    public DataProvider(CustomHttpClient customHttpClient) {
        this.httpClient = customHttpClient;
    }

    public String getData() {
        return httpClient.get("URL");
    }
}

DIP Solution

Make the module depend on an abstraction.

package com.devkuma.tutorial.solid.dip.bad;

import com.devkuma.tutorial.solid.dip.HttpClient;
import com.devkuma.tutorial.solid.dip.good.DataFetchClient;

public class DataProvider {
    public HttpClient httpClient;

    public DataProvider(HttpClient httpClient) {
        this.httpClient = httpClient;
    }

    public String getData() {
        return httpClient.get("URL");
    }
}

References