SOLID principles explained

Lets talk about SOLID principles. What they mean? and How are they useful?

2023-01-31 00:10:59 - Mohamad Abuzaid


SOLID is an acronym for five design principles used while writing software using OOP paradigm to make software designs more understandable, flexible, and maintainable. The SOLID principles are:

  1. Single responsibility principle (SRP): A class should have one and only one reason to change.
  2. Open-closed principle (OCP): A class should be open for extension but closed for modification.
  3. Liskov substitution principle (LSP): Subtypes should be substitutable for their base types.
  4. Interface segregation principle (ISP): A class should not be forced to implement interfaces it does not use.
  5. Dependency inversion principle (DIP): High-level modules should not depend on low-level modules, but both should depend on abstractions.

----------------------------

[S] The Single Responsibility Principle


(SRP) is the first principle of the SOLID design principles. It states that a class should have one and only one reason to change, meaning that a class should have only one job. A class that has multiple responsibilities is often referred to as a “Blob class” and can be difficult to understand, modify and test.

An example of a bad practice that violates the SRP is a class that is responsible for both calculating and printing the results of a calculation.

class Calculator {
    fun calculateSum(a: Int, b: Int): Int {
        return a + b
    }
    fun printSum(a: Int, b: Int) {
        val sum = calculateSum(a, b)
        println("The sum is: $sum")
    }
}


In this example, the Calculator class is responsible for both the calculation of the sum and the printing of the results. This violates the SRP because the class has two reasons to change: if the calculation needs to be changed, or if the way the results are printed needs to be changed.

A better practice would be to separate the responsibilities of the class into two different classes, one for calculation and one for printing.

class Calculator {
    fun calculateSum(a: Int, b: Int): Int {
        return a + b
    }
}

class Printer {
    fun printSum(sum: Int) {
        println("The sum is: $sum")
    }
}


In this example, the Calculator class is responsible only for the calculation of the sum, and the Printer class is responsible only for printing the results. This adheres to the SRP because each class has only one reason to change, making the code more maintainable.

----------------------------

[O] The Open-Closed Principle


(OCP) is the second principle of the SOLID design principles. It states that a class should be open for extension but closed for modification, meaning that a class should be designed in such a way that new behavior can be added through inheritance or composition, but existing behavior should not be modified. This promotes a design where the behavior of a class can be extended without changing the class itself, making the code more flexible and maintainable.

An example of a bad practice that violates the OCP is a class that contains a hard-coded list of shapes and a method to calculate the area of each shape.

class AreaCalculator {
    private val shapes = listOf("circle", "square", "triangle")
    fun calculateArea(shape: String): Double {
        return when (shape) {
            "circle" -> 3.14 * (5 * 5)
            "square" -> 5 * 5
            "triangle" -> 0.5 * (5 * 5)
            else -> 0.0
        }
    }
}


In this example, the AreaCalculator class is responsible for calculating the area of different shapes. If we want to add new shapes or change the calculation of area for any shape, we need to modify the class, this violates the OCP principle.

A better practice would be to create an interface Shape that defines a method to calculate the area, and then create separate classes for each shape that implement the Shape interface.

interface Shape {
    fun calculateArea(): Double
}

class Circle(private val radius: Double) : Shape {
    override fun calculateArea() = 3.14 * (radius * radius)
}

class Square(private val side: Double) : Shape {
    override fun calculateArea() = side * side
}

class Triangle(private val base: Double, private val height: Double) : Shape {
    override fun calculateArea() = 0.5 * (base * height)
}


In this example, the behavior of the class can be extended by creating new classes that implement the Shape interface, without modifying the existing class, this adheres to the OCP principle, making the code more flexible and maintainable.

----------------------------

[L] The Liskov Substitution Principle


(LSP) is the third principle of the SOLID design principles. It states that subtypes should be substitutable for their base types, meaning that objects of a superclass should be able to be replaced with objects of a subclass without altering the correctness of the program. This principle helps to ensure that objects of a subclass can be used interchangeably with objects of the superclass, without introducing any unexpected behavior.

An example of a bad practice that violates the LSP is a class hierarchy where a subclass overrides a method in a way that changes the method’s contract.

open class Rectangle {
    open var width: Int = 0
    open var height: Int = 0
    open fun area(): Int {
        return width * height
    }
}

class Square : Rectangle() {
    override var width: Int
        get() = super.width
        set(value) {
            super.width = value
            super.height = value
        }
    override var height: Int
        get() = super.height
        set(value) {
            super.width = value
            super.height = value
        }
}


In this example, the Square class is a subclass of the Rectangle class, but the Square class overrides the width and height properties in a way that changes the contract of the class. Specifically, the Square class sets both the width and height properties to the same value, so that a square is always a square, but this is not an actual square, because a square is a rectangle and it should have different width and height values. This means that if an object of the Rectangle class is replaced with an object of the Square class, the behavior of the program may be unexpected.

A better practice would be to create a new class Square that has its own properties and methods, and also extends Rectangle class.

open class Rectangle {
    open var width: Int = 0
    open var height: Int = 0
    open fun area(): Int {
        return width * height
    }
}

class Square : Rectangle() {
    var side: Int = 0
    override var width: Int
        get() = side
        set(value) {
            side = value
        }
    override var height: Int
        get() = side
        set(value) {
            side = value
        }
    override fun area(): Int {
        return side * side
    }
}


In this example, the Square class has its own properties and methods, so the Square class can be used interchangeably with the Rectangle class, without introducing any unexpected behavior. This adheres to the Liskov Substitution Principle, making the code more maintainable and predictable.

----------------------------

[I] The Interface Segregation Principle


(ISP) is the fourth principle of the SOLID design principles. It states that a class should not be forced to implement interfaces it does not use, meaning that a class should not be forced to implement methods it does not need. This principle encourages creating small, specific interfaces that are tailored to the needs of specific classes, rather than creating large, general interfaces that require classes to implement many methods they do not need.

An example of a bad practice that violates the ISP is a class that implements a large, general interface that contains many methods that the class does not need.

interface Shape {
    fun calculateArea(): Double
    fun calculatePerimeter(): Double
    fun draw(): Unit
    fun resize(): Unit
}

class Circle : Shape {
    var radius: Double = 0.0
    override fun calculateArea() = 3.14 * (radius * radius)
    override fun calculatePerimeter() = 2 * 3.14 * radius
    override fun draw() = println("Drawing circle")
    // resize is not needed for Circle class
    override fun resize() {}
}


In this example, the Circle class is forced to implement the resize() method, even though it is not needed. This violates the ISP principle because the class is being forced to implement methods that it does not need.

A better practice would be to create small, specific interfaces that are tailored to the needs of specific classes, rather than creating large, general interfaces that require classes to implement many methods they do not need.

interface CalculableArea {
    fun calculateArea(): Double
}

interface CalculablePerimeter {
    fun calculatePerimeter(): Double
}

interface Drawable {
    fun draw(): Unit
}

class Circle : CalculableArea, CalculablePerimeter, Drawable {
    var radius: Double = 0.0
    override fun calculateArea() = 3.14 * (radius * radius)
    override fun calculatePerimeter() = 2 * 3.14 * radius
    override fun draw() = println("Drawing circle")
}


In this example, the Circle class implements only the interfaces that it needs, adhering to the ISP principle, making the code more maintainable and flexible.

----------------------------

[D] The Dependency Inversion Principle


(DIP) is the fifth principle of the SOLID design principles. It states that high-level modules should not depend on low-level modules, but both should depend on abstractions, meaning that a class should depend on abstractions rather than concretions. This principle promotes a design where the high-level modules (such as the business logic) are not tightly coupled to the low-level modules (such as the data access layer), making the code more flexible and maintainable.

An example of a bad practice that violates the DIP is a class that depends on a specific implementation of a low-level module.

class Order {
    private val database = MySQLDatabase()

    fun saveOrder() {
        database.save("orders", "order_data")
    }
}


In this example, the Order class depends on a specific implementation of a low-level module, the MySQLDatabase class. This violates the DIP principle because the Order class is tightly coupled to the specific implementation of the MySQLDatabase class. If we want to change the database to PostgreSQL or any other database, we need to change the Order class as well.

A better practice would be to create an abstraction for the low-level module and have the high-level module depend on the abstraction.

interface Database {
    fun save(table: String, data: String)
}

class MySQLDatabase: Database {
    override fun save(table: String, data: String) {
        println("Saving data to MySQL database")
    }
}

class PostgreSQLDatabase: Database {
    override fun save(table: String, data: String) {
        println("Saving data to PostgreSQL database")
    }
}

class Order {
    private lateinit var database: Database
    fun setDatabase(database: Database) {
        this.database = database
    }
    fun saveOrder() {
        database.save("orders", "order_data")
    }
}


In this example, the Order class depends on an abstraction, the Database interface, rather than a specific implementation of a low-level module. This adheres to the DIP principle, making the code more flexible and maintainable. Now we can change the database to any other database by just creating a new implementation of the Database interface and injecting it into the Order class.

----------------------------

Summary

In conclusion, SOLID is intended to make software designs more understandable, flexible, and maintainable. Each principle promotes a specific aspect of software design, such as separation of concerns, extensibility, and decoupling. Adhering to these principles can lead to code that is easier to understand, modify, and test, making it more maintainable and flexible.

More Posts