Skip to content

walid-ashik/solid_principles

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 

Repository files navigation

Why S.O.L.I.D ?

The broad goal of the SOLID principles is to reduce dependencies so that engineers change one area of software without impacting others. Additionally, they’re intended to make designs easier to understand, maintain, and extend.

Using these design principles makes it easier for software engineers to avoid issues and to build adaptive, effective, and agile software.

Problems & Solution using SOLID

Readable: We are constantly reading old code as part of the effort to write code. We spend more time reading than writing code. This becomes more complex day by day when more code is added & team grows.

Testable: It should be easy to write automated tests that assert the system’s behavior. The profound reason is that we don’t want to spend too much time verifying that the system is effective. Running manual tests are very costlier than building software.

Extensible: It should be easy to add additional functionality to the system. A big part of this comes from readability. Readability is a necessary but not a sufficient element to enable extensibility. We implement SOLID patterns which enable readability.

Robust: Adding additional functionality should not introduce much risk of breaking existing functionality.

Maintainable: When a defect is reported, it should be easy to track it down and fix it.

By Applying S.O.L.I.D we can achieve all these things that mentioned above.

What is SOLID?

SRP - Single Responsibility Principle
A class should do one thing and therefore it should have only a single reason to change.

Open-Closed Principle
Classes should be open for extension and closed to modification.

Liskov Substitution Principle
Super class should be replacable by it's sub classs.

Interface Segregation Principle
Segregation means keeping things separated, and the Interface Segregation Principle is about separating the interfaces.

Dependency Inversion Principle
Classes should depend upon interfaces or abstract classes instead of concrete classes and functions.

Single Responsibility Principle

SRP also helps to have less Merge conflict as everything will be separated and place in separated class, There will be less merge conflicts.

Basic Example

UML Product Class

This Product object violates the SRP principle. Product class should not have the business responsibility like calculateRetailPrice() or calculateBusinessPrice()

Solution

Create separate Calculator class so changes become easy and introduce less merge conflicts.

UML Product Class Separation

so, final result will be this

UML Final Product Class of SRP

Another Example with Code

class Invoice(
    val book: Book,
    val quantity: Int,
    val discountRate: Double,
    val taxRate: Double
) {
    val total: Double

    init {
        total = calculateTotal()
    }

    fun calculateTotal(): Double {
        val price: Double = (book.price - book.price * discountRate) * quantity
        return price * (1 + taxRate)
    }

    // Violation: #1 - printing invoice should not be Invoice responsibility
    fun printInvoice() {
        println(quantity.toString() + "x " + book.name + " " + book.price + "$")
        println("Discount Rate: $discountRate")
        println("Tax Rate: $taxRate")
        println("Total: $total")
    }

    // Violation: #2 - saving file should not be Invoice responsibility
    fun saveToFile(filename: String?) {
        // Creates a file with given name and writes the invoice
    }
}

Solution

Create 2 classes InvoicePrinter & InvoicePersistance to delegate the separated responsibility

class InvoicePrinter(val invoice: Invoice) {
    
        fun print() {
            println(((invoice.quantity + "x " + invoice.book.name).toString() + " " + invoice.book.price).toString() + " $")
            System.out.println("Discount Rate: " + invoice.discountRate)
            System.out.println("Tax Rate: " + invoice.taxRate)
            System.out.println("Total: " + invoice.total + " $")
        }
    }
class InvoicePersistence(val invoice: Invoice) {
    
    fun saveToFile(filename: String?) {
        // Creates a file with given name and writes the invoice
    }
}
class Invoice(
    val book: Book,
    val quantity: Int,
    val discountRate: Double,
    val taxRate: Double
) {
    private val total: Double

    init {
        total = calculateTotal()
    }

    fun calculateTotal(): Double {
        val price: Double = (book.price - book.price * discountRate) * quantity
        return price * (1 + taxRate)
    }
}

So main.kt function will be

fun main(args: Array<String>) {
    val book = Book(
        name = "Clean Architecture",
        price = 1090.0
    )
    val invoice = Invoice(
        book = book,
        quantity = 1,
        discountRate = 1.2,
        taxRate = 15
    )
    
    val invoicePrinter = InvoicePrinter(invoice)
    invoicePrinter.print()
    
    val invoicePersistence = InvoicePersistence(invoice)
    invoicePersistence.saveToFile()
}

data class Book(
    val name : String,
    val price : Double
)

Open-Closed Principle

Let's assume, after first release Product Owner comes to us and want to add a new feature. From now on, we need to also save the invoice to server.
Seems like easy solution, right? Without thinking further we want to make this easy change. So, we open our InvoicePersistence class and add a new method,

class InvoicePersistence(val invoice: Invoice) {
    
    fun saveToFile(filename: String?) {
        // Creates a file with given name and writes the invoice
    }

    fun saveToServer(filename: String?) {
        // Implementation of saving file to server
    }
}

Problem

Now, you broke 2 rules,
1. Single Responsibility Principle: A class should be only one reason to change.
Look at the code, we are totally having a new reason now. saveToServer() which is totally different from saving invoice to a file locally.

2. Open-Closed Principle: A class should be open for extension & close for modification.
We're not extending a saveToServer() feature. We're modifying this class to adapt a new feature which break this rule.

Solution

We can introduce and take advantage of OOP's interface to fix the problem with Open-Closed principle.

interface InvoiceSaver {
    fun save()
} 

Now, let's implement this interface and add the requirements in two separated class so that it follows SRP

class Server : InvoiceSaver {
    override fun save() {
        // Save to server
    }
}

class FileSystem : InvoiceSaver {
    override fun save() {
        // Save to file
    }
}

Now, we also need to add what type of save strategy we'll use. So, let's create enum for invoice SavingType

enum class SaveType {
    File, Server
}

Now, we have to completely refactor the InvoicePersistence class to make it abid the SRP & Open-Closed principle. So, refactor it to this,

class InvoicePersistence() {
    fun store(invoice: Invoice): InvoiceSaver = when (invoice.saveType) {
        SaveType.File -> Server()
        SaveType.Server -> FileSystem()
    }
}

Without refactoring/changing the code, we can not introduce new principles.

When a new feature or change request comes and the code is not Open-Closed principle compliant then we should first refactor the code to accept the change as Kent Beck said,

make the change easy, then make the easy change.

Everything is now set, so the final main.kt will be

fun main(args: Array<String>) {
    val book = Book(
        name = "Clean Architecture", price = 1090.0
    )
    val invoice = Invoice(
        book = book, quantity = 1, discountRate = 1.2, taxRate = 15.0, saveType = SaveType.File
    )
    
    // InvoicePersistence stores the invoice based on SaveType and
    // delegates it implementation on a separate class
    val invoicePersistence = InvoicePersistence()
    invoicePersistence.store(invoice)

}

Handle change request

Now, if tomorrow Product Owner asks for saving the invoice to LocalDatabase, the cnange will be very easy for us to add without risking other codes,

we just new to add LocalDatabase save type to SaveType enum class and we need to create another class which only be responsible for saving the invoice to local database. Here's the code,

  1. Add a new save type LocalDatabase
enum class SaveType {
-    File, Server
+    File, Server, LocalDatabase, 
}
  1. Add LocalDatabase class to implement saving file to local database
class LocalDatabase: InvoiceSaver {
    override fun save() {
        // Save to local database
    }
}
  1. Add LocalDatabase to InvoicePersistence for specifying saving strategy
class InvoicePersistence() {
    fun store(invoice: Invoice): InvoiceSaver = when (invoice.saveType) {
        SaveType.File -> Server()
        SaveType.Server -> FileSystem()
+        SaveType.LocalDatabase -> LocalDatabase()
    }
}

That's all. Our InvoicePersistence is now capabale of saving invoice to even LocalDatabase.

Check that when we implemented new saving to database feature, we didn't, modified any of our class and only added new feature change to new class. So, there's no risk of breaking other parts of the code as we almost never touched other code.

Liskov Substitution Principle

Original Theory:

Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T.

Hard to digest right? so, simply

Super class should be replacable by it's sub classs.

Let's try with a different and easy example this time. Assume that we have a Bird class that has common behavior of any bird like fly().
So, In our program if we want to create any kind of Bird like Parrot our obvious choice is to extend Bird so that it has those bird's behavior/characteristic. Lets' see the code,

class Bird {
    fun fly() {
        // implementation of fly
    }
    
    //...
}

So, if we introduce a new bird called, Parrot this will be the code,

class Parrot : Bird() {
    //...
}

And it's totally fine. But what if we need to create Ostrich which can not fly. So, if we do something like this,

class Ostrich : Bird() {
    override fun fly() {
        throw Exception("I can't fly :( ")
    }
}

So this is the violation of Liskov's theory. If we want to fly() a bird and have written our code that takes Bird as input then passing Ostrich to that break the code,

fun main(args: Array<String>) {
    val parrot = Parrot()
    val ostrich = Ostrich()
    
    val action = Action()
    action.makeBirdFly(parrot) // that's fine
    
    action.makeBirdFly(ostrich) // oops! liskov substitution
}

class Action {
    fun makeBirdFly(bird: Bird) {
        bird.fly()
    }
}

Interface Segregation Principle

Segregation means keeping things separated, and the Interface Segregation Principle is about separating the interfaces.

Let's take the above Bird example. As you've seen previously how Ostrich bird example violated LS principle. We'll be solving that using Interface Segregation Principle.

We can say LS principle is the theory of a problem that we can solve using Interface Segregation.

Remember, Ostrich can't fly so by extending Bird we violate LS principle. So to solve this we can introduce a new interface called Flyable

interface Flyable {
    fun fly()
}

Our goal is to separate the common behavior using interface for birds here. Now we'll create two kind of birds,

class Bird {
    // bird characteristics & behaviors
}

class FlyingBird: Bird() , Flyable {
    override fun fly() {
    }
}

So, now Parrot will extend FlyingBird which is a bird kind that also fly but Ostrich only extends Bird as it can't fly

class Ostrich : Bird() {
    // it's bird that can't fly
}

class Parrot : FlyingBird() {
    // it's a bird that can fly too
}

So, we just solved LS problem using ISP.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published