Published: Nov 06, 2022

SOLID Principles

Definition

  • Single-responsibility

    • A module should be responsible to one, and only one, actor
    • A class should have only one reason to change
  • Open-closed

    • Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification
  • Liskov substitution

    • Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
  • Interface segregation

    • Clients should not be forced to depend upon interfaces that they do not use
  • Dependency inversion

    • Depend upon abstractions, not concretions

An Example

Let’s try to apply SOLID to following code:

interface Shape {

}

class Rectangle(val width: Double, val height: Double) : Shape() {
    
}

class Circle(val radius: Double) : Shape() {
    
}

class Cube(val length: Double): Shape() {
    
}

class Logger() {
    fun log(msg: String) {
        println(msg)
    }
}

class ShapeCalculator() {
    private val logger: Logger = Logger()

    fun sumAreas(shapes: List<Shape>): Double {
        logger.log("calculating sum of areas...")
        return shapes.sumOf {
            if (it is Rectangle) {
                it.width * it.height
            } else if (it is Circle) {
                it.radius * it.radius * Math.PI
            } else if (it is Cube) {
                6 * it.length.pow(2)
            } else {
                0.0
            }
        }
    }

    fun sumVolumes(shapes: List<Shape>): Double {
        logger.log("calculating sum of volumes...")
        return shapes.sumOf {
            if (it is Cube) {
                it.length.pow(3)
            } else {
                0.0
            }
        }
    }

    fun writeSumsToFile(shapes: List<Shape>) {
        File("sums.txt").printWriter().use {
            it.println("The sum of the area of all shapes is: ${sumAreas(shapes)}")
            it.println("The sum of the volume of all shapes is: ${sumVolumes(shapes)}")
        }
    }
}

1. Single Responsibility

writeSumsToFile() doesn’t quite fit in with the other calculation methods. It’s responsibility different - writing calculated values to a file. Create a new class for this method.

class ShapeSumFileWriter {
    private val shapeCalc = ShapeCalculator()
    
    fun writeSumsToFile(shapes: List<Shape>) {
        File("sums.txt").printWriter().use {
            it.println("The sum of the area of all shapes is: ${shapeCalc.sumAreas(shapes)}")
            it.println("The sum of the volume of all shapes is: ${shapeCalc.sumVolumes(shapes)}")
        }
    }
}

2. Open-closed

Have a look at sumAreas() and sumVolumes(). If we want to add more shapes in the future we have to modify the ShapeCalculator class, but SOLID states that classes should be closed for modification and open to extension.

One way to solve this problem is to move the calculation of the area/volume to the shapes instead.

interface Shape { 
    fun getArea(): Double
    fun getVolume(): Double
}

class Rectangle(val width: Double, val height: Double) : Shape {
    override fun getArea(): Double {
        return width * height
    }

    override fun getVolume(): Double {
        throw Exception("Cannot calculate volume of a Rectangle")
    }
}

class Circle(val radius: Double) : Shape {
    override fun getArea(): Double {
        return radius * radius * Math.PI
    }

    override fun getVolume(): Double {
        throw Exception("Cannot calculate volume of a Circle")
    }
}

class Cube(val length: Double) : Shape {
    override fun getArea(): Double {
        return 6 * length.pow(2.0)
    }

    override fun getVolume(): Double {
        return length.pow(3.0)
    }
}


class Logger() {
    fun log(msg: String) {
        println(msg)
    }
}

class ShapeCalculator() {
    private val logger: Logger = Logger()

    fun sumAreas(shapes: List<Shape>): Double {
        logger.log("calculating sum of areas...")
        return shapes.sumOf {
            it.getArea()
        }
    }

    fun sumVolumes(shapes: List<Shape>): Double {
        logger.log("calculating sum of volumes...")
        return shapes.sumOf {
            it.getVolume()
        }
    }
}

class ShapeSumFileWriter {
    private val shapeCalc = ShapeCalculator()

    fun writeSumsToFile(shapes: List<Shape>) {
        File("sums.txt").printWriter().use {
            it.println("The sum of the area of all shapes is: ${shapeCalc.sumAreas(shapes)}")
            it.println("The sum of the volume of all shapes is: ${shapeCalc.sumVolumes(shapes)}")
        }
    }
}

3. Liskov substitution

Consider following code:

val shape: Shape = Cube(10.0)
shape.getVolume()

val shape: Shape = Circle(10.0)
shape.getVolume()

According to Liskov substitution, both Shape.getVolume() calls should behave similar, but one of them throws an exception. In this case this can be easily fixed by replacing the throw Exception() with a return 0.0.

4. Interface Segregation

Don’t like the return 0.0 solution from the previous step? That’s good because there really is no need to have a getVolume() method for 2-dimensional shapes in the first place. So let’s create a Shape2D and Shape3D interface.

We keep the Shape interface so it’s still possible to have collections that contain both 2D and 3D shapes as can be seen in writeSumsToFile().

interface Shape {
}

interface Shape2D: Shape {
    fun getArea(): Double
}

interface Shape3D: Shape2D {
    fun getVolume(): Double
}

class Rectangle(val width: Double, val height: Double) : Shape2D {
    override fun getArea(): Double {
        return width * height
    }
}

class Circle(val radius: Double) : Shape2D {
    override fun getArea(): Double {
        return radius * radius * Math.PI
    }
}

class Cube(val length: Double) : Shape3D {
    override fun getArea(): Double {
        return 6 * length.pow(2.0)
    }

    override fun getVolume(): Double {
        return length.pow(3.0)
    }
}

class Logger() {
    fun log(msg: String) {
        println(msg)
    }
}

class ShapeCalculator() {
    private val logger: Logger = Logger()

    fun sumAreas(shapes: List<Shape2D>): Double {
        logger.log("calculating sum of areas...")
        return shapes.sumOf {
            it.getArea()
        }
    }

    fun sumVolumes(shapes: List<Shape3D>): Double {
        logger.log("calculating sum of volumes...")
        return shapes.sumOf {
            it.getVolume()
        }
    }
}

class ShapeSumFileWriter {
    private val shapeCalc = ShapeCalculator()

    fun writeSumsToFile(shapes: List<Shape>) {
        File("sums.txt").printWriter().use {
            val areaSum = shapeCalc.sumAreas(shapes.filterIsInstance(Shape2D::class.java))
            val volumeSum = shapeCalc.sumAreas(shapes.filterIsInstance(Shape3D::class.java))
            it.println("The sum of the area of all shapes is: $areaSum")
            it.println("The sum of the volume of all shapes is: $volumeSum")
        }
    }
}

5. Dependency inversion

Have a look at the logger inside ShapeCalculator:

private val logger: Logger = Logger()

ShapeCalculator depends on the concrete implementation of this specific Logger. This could be an issue if the Logger class becomes more complicated (for example: sends logs to a specific server not available in a test environment).

Dependency inversion states to depend on abstractions instead. After creating an ILogger interface and using Depdency Injection the code looks like this:

interface ILogger {
    fun log(msg: String)
}

class Logger() : ILogger {
    override fun log(msg: String) {
        println(msg)
    }
}

class ShapeCalculator(private val logger: ILogger) {
    fun sumAreas(shapes: List<Shape2D>): Double {
        logger.log("calculating sum of areas...")
        return shapes.sumOf {
            it.getArea()
        }
    }

    fun sumVolumes(shapes: List<Shape3D>): Double {
        logger.log("calculating sum of volumes...")
        return shapes.sumOf {
            it.getVolume()
        }
    }
}

Now the logger can easily be replaced with a different implementation while testing the ShapeCalculator class.

Result

The final code after applying SOLID is longer but much easier to read, understand and extend. Taking the time to apply these principles initially is going to save a lot of time and headaches later on.

interface Shape {}

interface Shape2D: Shape {
    fun getArea(): Double
}

interface Shape3D: Shape2D {
    fun getVolume(): Double
}

class Rectangle(val width: Double, val height: Double) : Shape2D {
    override fun getArea(): Double {
        return width * height
    }
}

class Circle(val radius: Double) : Shape2D {
    override fun getArea(): Double {
        return radius * radius * Math.PI
    }
}

class Cube(val length: Double) : Shape3D {
    override fun getArea(): Double {
        return 6 * length.pow(2.0)
    }

    override fun getVolume(): Double {
        return length.pow(3.0)
    }
}

interface ILogger {
    fun log(msg: String)
}

class Logger() : ILogger {
    override fun log(msg: String) {
        println(msg)
    }
}

class ShapeCalculator(private val logger: ILogger) {
    fun sumAreas(shapes: List<Shape2D>): Double {
        logger.log("calculating sum of areas...")
        return shapes.sumOf {
            it.getArea()
        }
    }

    fun sumVolumes(shapes: List<Shape3D>): Double {
        logger.log("calculating sum of volumes...")
        return shapes.sumOf {
            it.getVolume()
        }
    }
}

class ShapeSumFileWriter {
    private val shapeCalc = ShapeCalculator()

    fun writeSumsToFile(shapes: List<Shape>) {
        File("sums.txt").printWriter().use {
            val areaSum = shapeCalc.sumAreas(shapes.filterIsInstance(Shape2D::class.java))
            val volumeSum = shapeCalc.sumAreas(shapes.filterIsInstance(Shape3D::class.java))
            it.println("The sum of the area of all shapes is: $areaSum")
            it.println("The sum of the volume of all shapes is: $volumeSum")
        }
    }
}