Clean Architecture with SOLID Principles: Part 1

Clean Architecture with SOLID Principles: Part 1

This is the first part of the Modern Android App Development with Clean Architecture Series where we are going to learn about the SOLID principles which are the building stones and serve as the fundamentals of clean architecture.

Lets get started..

SOLID principles

SOLID principles are a set of guidelines that are followed by software developers to come up with clean and maintainable code. They go hand in hand with OOP (Object Oriented Programming). To achieve a clean architecture, we need to understand that it's not a template but rather a combination of SOLID principles and OOP.

These principles are :

  • S - Single Responsibility Principle

  • O - Open Closed Principle

  • L - Liskov Substitution Principle

  • I - Interface Segregation Principle

  • D - Dependency Inversion Principle

Let's look at the SOLID principles in more detail.

Single responsibility principle

The single responsibility principle states that a class should have one and only one responsibility, hence only one reason to change. If we have a class with more than one behavior, then it's time to change the class as soon as possible to reduce the responsibilities. We are using the term "class" since we will be dealing with Kotlin in this course, which can also apply to Java developers.

Software will always need new requirements with every new feature and this can come with side effects so with the single responsibility principle adhered to, it will be easier to apply and implement these changes, reduce the number of bugs, and be easy to detect errors. But not every class should have one responsibility, apply this principle with care to avoid a lot of classes which can lead to our codebase ending up with a lot of dependencies. It's good to simplify our code, but don't oversimplify.

Let's look at an example

data class Student(
    var id:Int,
    var name:String,
    var course:String

){
    fun signIn(){}

    fun signUp(){}

    fun calculateFeeBalance(){}
}
fun main() {
    val student = Student(1,"Peter","Android Engineering")
    println("Hello,  ${student.name
}

Output
//Hello,  Peter

We have a Student data class that defies the Single Responsibility Principle since it contains more than one responsibility. Consequently, it becomes susceptible to multiple reasons for change. In such scenarios, we will need to change our classes to follow the Single Responsibility Principle.

To address this issue, we should relocate the signIn() method and the signUp() method to another class since they are closely related and cohesive in function. Similarly, we should move the calculateFeeBalance() method on line 11 to another class, thereby leaving the data class with a single responsibility.

Open-closed principle

This principle states that a class should be open for extension but closed for modifications, meaning a class can be extended through polymorphism to add new functionality and behaviors but the source should remain untouched or unchanged.

Lets have a look at an example

interface Shapes {
    fun area(): Double
}

class Circles(val radius: Double) : Shapes {
    override fun area(): Double = Math.PI * radius * radius
}

class Rectangles(val width: Double, val height: Double) : Shapes {
    override fun area(): Double {
        return width * height
    }

}

fun main() {

    val circle = Circles(7.0)
    val rectangle = Rectangles(8.2, 3.5)

    println("Area of circle  = ${circle.area()}")
    println("Area of rectangle = ${rectangle.area()}")
}

Output
//Area of circle  = 153.93804002589985
//Area of rectangle = 28.699999999999996

We have the Shape interface with only one function area that returns a double.

We have the Circle class that extends the Shape interface and takes in the radius parameter of type double. We also override the area function and provide our own implementation which is Math.PI * radius * radius without modifying the source code and we can add more shapes.

We have the Rectangles class that accepts two parameters and accepts the Shapes class forcing us to override the area function and provide our own implementation to return the area of the rectangle which width * height and we can add as many shapes as we can without modifying the source code which is the whole point of the open/closed principle where we extend class capabilities without modifying their source code.

Liskov substitution principle

This principle states that a base or superclass (parent) object should be able to be replaced by subclasses or child classes without changing the program behavior, and so the child class should be able to do everything the parent class can do which can be achieved through inheritance, which promotes polymorphism.

Let us look at an example

import java.lang.Math

abstract class Shape {
    abstract fun calculateArea(): Double
}

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

}

class Circle(var radius: Double) : Shape() {
    override fun calculateArea(): Double {
        return java.lang.Math.PI * radius * radius
    }

}

fun main() {
    val rectangle:Shape = Rectangle(4.0, 6.0)
    val circle:Shape = Circle(5.6)
    println("Area of Rectangle: ${rectangle.calculateArea().toInt()}")
    println("Area of Circle: ${circle.calculateArea()}")
}

Output
//Area of Rectangle: 24
//Area of Circle: 98.52034561657591

We have used the main method; the variables hold references to the Shape class. However, these references are replaced by references to Rectangle and Circle derived classes. This is because Shape serves as their base class, enabling them to perform the same functionality as the parent class without altering the behavior of the program.

Interface segregation principle

This principle states that a client should not be forced to implement methods that it does not intend to use and we should only write interfaces that are specific to the client's needs and not general-purpose interfaces that may be wasteful and may cause bugs.

Try designing smaller interfaces that specifically serve a certain client and are relevant to it and split it when the need arises.

Here is an example.

interface Vehicle {
    fun buildEngine()

    fun fourWheel()

    fun twoWheel()

    fun bulletProofSystem()
}

class NormalCar() : Vehicle {
    override fun buildEngine() {
        TODO("required")
    }

    override fun fourWheel() {
        TODO("Not required")
    }

    override fun twoWheel() {
        TODO("required")
    }

    override fun bulletProofSystem() {
        TODO("Not required")
    }

}

class PresidentialCar() : Vehicle {
    override fun buildEngine() {
        TODO("required")
    }

    override fun fourWheel() {
        TODO("required")
    }

    override fun twoWheel() {
        TODO("required")
    }

    override fun bulletProofSystem() {
        TODO("required")
    }

}

We define an interface Vehicle, which contains four methods as illustrated where we define the NormalCar and PresidentialCar classes, respectively, both implementing the Vehicle interface. The issue arises with the NormalCar, as it is forced to implement methods that it does not use, specifically fourWheel() and bulletProofSystem(). To address this, we should create two client-specific interfaces, each containing methods tailored to the needs of the corresponding class.

interface Vehicle1 {
    fun buildEngine()

    fun fourWheel()

    fun twoWheel()

    fun bulletProofSystem()
}

interface Vehicle2 {
    fun buildEngine()

    fun twoWheel()
}

class NormalCar() : Vehicle2 {
    override fun buildEngine() {
        TODO("required")
    }

    override fun twoWheel() {
        TODO("required")
    }

}

class PresidentialCar() : Vehicle1 {
    override fun buildEngine() {
        TODO("required")
    }

    override fun fourWheel() {
        TODO("required")
    }

    override fun twoWheel() {
        TODO("required")
    }

    override fun bulletProofSystem() {
        TODO("required")
    }

}

We introduce class-specific interfaces, namely Vehicle1 and Vehicle2. We ensure that each class only implements interfaces containing methods relevant to its functionality.

We defined NormalCar() and PresidentialCar() classes to implement interfaces tailored to their specific requirements. As a result, these classes can focus on implementing methods they actually need without having to override methods that serve no purpose for their functionality.

Dependency inversion

The Dependency Inversion Principle represents that when we build software, a class responsible for execution shall not depend on lower-level modules or classes. Instead, we should use interfaces to connect them and to break the dependency. Additionally, these interfaces should not depend on the details; instead, they should depend on the abstraction. This means that the higher-level modules of our program don't need to know exactly how the lower-level modules work; they just need to know what they do. And, the interfaces shouldn't know about the specific implementations either; they just need to describe what should be done.

Look at the below illustration

The above illustration shows the dependency diagram in Android applications and we can see the ViewModel depends on the repository interface and not the implementation and the repository implementation depends on the interface to implement its members.

In our next article we will dive into the synergy between clean architecture and SOLID principle and how they promote a robust code base that can easily be tested and maintained. We will explore the intricacies of building with multiple modules and the relationships between them.

Happy coding folks.