Posts Tagged

programming

Every Developer MUST know SOLID Principles

photo by Frantzou Fleurine on Unsplash

SOLID will help you make your code extendable, more understandable, maintainable and encapsulated with a simple application of what you will see in this article.

NOTE: The code samples are written with Kotlin. The samples I use in this article are simple to make the principles easy to understand. It is not necessarily directly applicable in the real world. What i am trying to do here is explaining the 5 principles in the simplest manner. Let us dive in now.

What S.O.L.I.D stands for?

  • S: Single Responsibility Principle
  • O: Open-Closed Principle
  • L: Liskov Substitution Principle
  • I: Interface Segregation Principle
  • D: Dependency Inversion Principle

1- SRP: Single Responsibility Principle

A class should have one and only one reason to change, meaning that a class should only have one job.

This principle is applicable not only for classes, but also for functions, software components, and microservices. The idea of this principle is Decoupling and Cohesion the components from each other. So that change in one component must not cause a change in another.

CODE:

class Car {
constructor(name: String) { /. . ./ }
fun getCarName() { /. . ./ }
fun saveCarToDB(newCar: Car) { /. . ./ }
}

The Car class violates the SRP. Why?

The constructor and getCarName are both for properties management. But the saveCarToDB function manage the Storage of the new car. which add another responsibility for the Car class. The SRP version of this example is to split the class into two with ONE responsibility for each.

BETTER CODE:

class Car {
constructor(name: String) { /. . ./ }
fun getCarName() { /. . ./ }
}
class CarDB {
fun saveCarToDB(newCar: Car) { /. . ./ }
fun getCarFromDB(carName: String) : Car { /. . ./ }
}

Applying this principle in every and each component makes the code Cohesive and Decoupled.

2- OCP: Open-Closed Principle

Software objects or entities (Classes, modules, functions) should be open for extension, but closed for modification.

That means A class should have one and only one reason to change, meaning that a class should only have one job.

Let’s continue with the Car‘s example to iterate thorough cars and count their doors count:

CODE:

class Car {
var name : String = ""
constructor(name: String) {
this.name = name
}
}
fun main(args: Array) {
var cars = arrayOf(Car("BMW"), Car("MINI"), Car("VW"))
printCarDoorCount(cars)
}
fun printCarDoorCount(cars: Array) {
cars.forEach { car ->
var doorsCount = 0
if (car.name == "BMW") {
doorsCount = 4
} else if (car.name == "VW") {
doorsCount = 4
} else if (car.name == "MINI") {
doorsCount = 2
}
println("${car.name} has $doorsCount")
}
}

The function printCarDoorCount violates the opened-closed principle. Because it cannot be closed against a new kinds of cars!

Adding new Car will force to add new condition in the if-else in printCarDoorCount function. That was quit a simple example. If you have many functions like this in your application, unfortunately, you will have to modify them all. If you miss one of them, most probably you will have your app returning unexpected results.

BETTER CODE:

abstract class Car {
var name : String = ""
constructor(name: String) {
this.name = name
}
abstract fun doorsCount() : Int
}
class Bmw() : Car("BMW") {
override fun doorsCount() : Int {
return 4
}
}
class MiniCooper() : Car("MiniCooper") {
override fun doorsCount() : Int {
return 2
}
}
class Volkswagen() : Car("Volkswagen") {
override fun doorsCount() : Int {
return 4
}
}
class Fiat() : Car("Fiat") {
override fun doorsCount() : Int {
return 3
}
}
fun main(args: Array) {
var cars = arrayOf(Bmw(), MiniCooper(), Volkswagen(), Fiat())
printCarDoorCount(cars)
}
fun printCarDoorCount(cars: Array) {
cars.forEach { car ->
println("${car.name} has ${car.doorsCount()}")
}
}

Car now has an abstract function, it may be called virtual method in some languages. We have each new car extends from our abstract Car class.

Each and every new Car will have to decide on how many doors will it have. printCarDoorCount will iterate on the Cars array and only call the car’s doorsCount().

Now, if we add a new car, printCarDoorCount will not change. All you need to do is to add the new car to the cars array. You can see the Fiat class upside or you may add your favorite car too 🙂

Now printCarDoorCount applies and conforms the OCP principle. 💪🏻

3- LSP: Liskov’s Substitution Principle

A sub-class must be substitutable for its super-class

This means, simply, the Parent class should be able to be replaced by any of its Subclasses in any region of the code.

As a sign for violating this principle, checking the type of class and doing something depending on that (That also may break OCP).

Let us continue with our Car’s example. We will write a function that will print the Car’s engine capacity:

CODE:

fun printEngineCapacity(cars: Array){
for (car in cars){
if (car is Bmw) {
println("${car.name} has 3400 CC Engine")
} else if (car is Volkswagen) {
println("${car.name} has 2600 CC Engine")
} else if (car is MiniCooper) {
println("${car.name} has 3000 CC Engine")
} else if (car is Fiat) {
println("${car.name} has 1600 CC Engine")
}
}
}

The function printEngineCapacity will work fine and print each car’s engine capacity. But, we are here for writing a good code. This violates LSP (and OCP).

If we add any new Car’s type and send it with the array, the printEngineCapacity function will not work as expected. We have to add new else-if to our function. if the function accepts Car’s (Super-Class) as parameter, it should not check its Sub-classes inside that function.

BETTER CODE:

abstract class Car {
var name : String = ""
constructor(name: String) {
this.name = name
}
abstract fun doorsCount() : Int
abstract fun engineCapacity(): Int
}
class Bmw() : Car("BMW") {
override fun doorsCount() : Int {
return 4
}
override fun engineCapacity() : Int {
return 3400
}

}
class MiniCooper() : Car("MiniCooper") {
override fun doorsCount() : Int {
return 2
}
override fun engineCapacity() : Int {
return 3000
}

}
class Volkswagen() : Car("Volkswagen") {
override fun doorsCount() : Int {
return 4
}
override fun engineCapacity() : Int {
return 2600
}

}
class Fiat() : Car("Fiat") {
override fun doorsCount() : Int {
return 3
}
override fun engineCapacity() : Int {
return 1600
}

}
fun main(args: Array) {
var cars = arrayOf(Bmw(), MiniCooper(), Volkswagen(), Fiat())
printEngineCapacity(cars)
}
fun printEngineCapacity(cars: Array){
for (car in cars){
println("${car.name} has ${car.engineCapacity()} CC Engine Capacity")
}
}

Now the printEngineCapacity method doesn’t need to know the type of Car to know the Engin Capacity, it just calls the engineCapacity() method of the Car type because by contract (we decided in abstract Car class) a sub-class of Car class must implement the engineCapacity() function.

4- ISP: Interface Segregation Principle:

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

In other words, that means many client specific interfaces are better than one general interface (Where user will be forced to implement methods that will not use).

This example will be a bit different. Lets check this code:

CODE:

interface ISport {
fun swim()
fun run()
fun longJump()
fun highJump()

fun getReady()
fun stop()
}
class Swimming : ISport {
var isReady = false
override fun swim() {
if (isReady) {
println("Swimming…")
} else {
println("Not Ready Yet")
}
}

override fun run() {}
override fun longJump() {}
override fun highJump() {}


override fun getReady() {
isReady = true
println("Ready to Swim")
}

override fun stop() {
println("Stopped Swimming")
}
}
fun main(args: Array) {
val swimmerFoo = Swimming()
swimmerFoo.getReady()
swimmerFoo.swim()
}

Interface segregation principle actually deals with the disadvantages of implementing big interfaces.

ISport interface knows more than it needs to know. The shared methods between all Sport classes (in our example) are getReady() and stop(). In Swimming class, we have implemented extra unused functions run(), longJump(), and highJump().

If we add another Sport, say Running, that extends from ISport, We will implement another 3 extra unused methods (swim(), longJump(), highJump()). So let’s see the better way of doing that.

BETTER CODE:

interface ISport {
fun getReady()
fun stop()
}


interface ISwimming : ISport {
fun swim()
}

interface IRunning : ISport {
fun run()
}

class Swimming : ISwimming {
var isReady = false
override fun swim() {
if (isReady) {
println("Swimming…")
} else {
println("Not Ready Yet")
}
}
override fun getReady() {
isReady = true
println("Ready to Swim")
}
override fun stop() {
println("Stopped Swimming")
}
}

class Running : IRunning {
var isReady = false
override fun run() {
if (isReady) {
println("Running…")
} else {
println("Not Ready Yet")
}
}
override fun getReady() {
isReady = true
println("Ready to Run")
}
override fun stop() {
println("Stopped Swimming")
}
}

fun main(args: Array) {
val swimmerFoo = Swimming()
swimmerFoo.getReady()
swimmerFoo.swim()

val runnerBoo = Running()
runnerBoo.getReady()
runnerBoo.run()
}

Now ISport contains only the methods that needs to be implemented in all inhereted classes. Only the Swimming will have to implement swim() in ISwimming interface.

NOTE: you can add start() method to ISport interface and implement the start() method in each class. But for the sake of explaining the ISP I added swim, run, highJump, and longJump methods.

5- DIP: Dependency Inversion Principle:

A. High-level modules should not depend on low-level modules. Both should depend on abstractions.
B. Abstractions should not depend on details. Details should depend on abstractions.

In another words: Dependency should be on abstractions not concretions

CODE:

open class Worker {
open fun work() {
println("General Worker is now working")
}
}

class Accountant : Worker() {
override fun work() {
println("Accountant is doing the calculations now")
}
}

class Manager {
var workers : ArrayList = arrayListOf()
fun addWorker(worker: Worker){
workers.add(worker)
}
fun manageWorkers() {
for (worker in workers) {
worker.work()
}
}
}

fun main(args: Array) {
val manager = Manager()
manager.addWorker(Worker())
manager.addWorker(Accountant())

manager.manageWorkers()
}

That was a bad example since dependency is on concretions not on abstractions.

BETTER CODE:

interface IWorker {
fun work()
}

interface IManager {
fun addWorker(worker: IWorker)
fun manageWorkers()
}

class GeneralWorker : IWorker{
override fun work() {
println("General Worker is now working")
}
}

class Accountant : IWorker {
override fun work() {
println("Accountant is doing the calculations now")
}
}

class Manager : IManager {
var workers : ArrayList = arrayListOf()
override fun addWorker(worker: IWorker)

override fun addWorker(worker: IWorker) {
workers.add(worker)
}

override fun manageWorkers() {
for(worker in workers){
worker.work()
}
}
}

fun main(args: Array) {
val manager = Manager()
manager.addWorker(GeneralWorker())
manager.addWorker(Accountant())

manager.manageWorkers()
}

In the better version code, both high-level modules and low-level modules depend on abstractions. Of course, this comes with overhead of writing additional code, the advantages that DIP provides outweigh the extra effort. So this principle is not to be applied for every and each class and module. For instance, If we have a class with functionality that is more likely to remain unchanged in the future there is no need to apply this principle.

Conclusion:

Those were the five SOLID principles which every software developer must know. I tried to use simple examples to make it as easy as possible for beginner developer to understand.

It might be hard at first to conform to all these principles at every and each software you write, but with consistent practice and devotion, it is going to become a part of your software and mentality of writing and will greatly have a huge impact on the maintainability and readability of our software or app.

NOTE: Since the code used in this article is Kotlin, You can run and change the example codes written here online on https://try.kotlinlang.org