Understanding SOLID: Open Close principle

Understanding SOLID: Open Close principle

Open Close principle

Software entities (functions, classes, modules, etc) should be open for extension and closed for modification. Bertrand Meyer 1988

as we can observe from this principle, entities should be extendable but closed for changes, let’s break this into pieces to understand it more.

Open for extension:

Extension here does not mean actual extends another class, but it means to extends its behavior by adding new behavior, like adding a new class to a module, adding a new method to a class, or adding a new behavior from outside to a function without changing the actual method code.

Close for Modification:

The modification here means, changing some logic inside a method, to add new requirements.

always leave the business details to be specified from outside as much as you can, if the unit of code depends on a set of business details like business conditions, switch cases, or any similar expressions, try to refactor it to receive these details, from the outside, so the unit of code will remain close for modification and open for extension. let’s code 🧑🏻‍💻

Let’s imagine if we need to build a GUI framework and we need to draw some shapes…

in a bad coding style it would be like this:

class Square{
    fun drawSquare() {
        println("draw a square")
    }
}
class Circle{
    fun drawCircle() {
        println("draw a circle")
    }
}

we declare entities for square and circle shapes as a start.

fun drawAllShapes(list: List<Any>) {
    for (element in list) {
        when (element) {
            is Square -> {
                element.drawSquare()
            }
            is Circle -> {
                element.drawCircle()
            }
            else -> {
                println("unknown shape")
            }
        }
    }
}

to draw them we need to loop over the list of shapes and check for their types to draw each one as specified by its class.

what if I need to add a new shape to the GUI framework?

First I will define the new shape

class Rectangle{
    override fun drawRectangle() {
        println("draw a rectangle")
    }
}

then I have to Change drawAllShapes by adding a new checker for Rectangle

fun drawAllShapes(list: List<Any>) {
    for (element in list) {
        when (element) {
            ...
            is Rectangle -> {
                element.drawRectangle()
            }
                        ...
        }
    }
}

What if I need to add another shape like Triangle? I have to modify drawAllShapes again!

Abstraction is the Key

If an entity is depending on abstraction, then it is depending on a fixed contract, unchangeable, yet the behavior could be extended by an unbounded group of subtypes, and each one can change the behavior as required.

in our case, we can abstract the shapes into a Shape interface, and define what are shape behaviors, then each subclass can specify how to do these functionalities like drawing the shape.

interface Shape {
    fun draw()
}
class Square : Shape{
    override fun draw() {
        println("draw a square")
    }
}

class Circle: Shape{
    override fun draw() {
        println("draw a circle")
    }
}

so drawAllShapes will not care about the shape type, it will depend on an abstraction, and each subtype will know how to draw itself at runtime.

fun drawAllShapes(list: List<Shape>) {
    for (element in list) {
        element.draw()
    }
}
fun main () {
    val listOfShapes: List<Shape> =listOf(
        Square(),
        Circle()
    )
        drawAllShapes(listOfShapes)
}

what if I need to add a new shape Triangle for example

class Triangle: Shape{
    override fun draw() {
        println("draw a triangle")
    }
}
// no changes here
fun drawAllShapes(list: List<Shape>) {...}

fun main () {
    val listOfShapes: List<Shape> = listOf(
        Square(),
        Circle(),
        Triangle() // add the new shape here
    )
    drawAllShapes(listOfShapes)
}

all that we have to do is add a new entity Triangle then we add it with the shapes to be drawn, so we pass the new requirement to the method, and that’s it.

Enough drawing, let’s have a more real-life example:

let’s define a function to calculate an employee's net salary…

data class Employee(
    val grossSalary: Float,
    val shift: String,
    val vacation: Int,
    val overtime: Float,
    val level: Int,
)

fun calculateEmployeeNetSalary(employee: Employee): Float {
    var calculatedSalary = employee.grossSalary

    if (employee.vacation > 0) {
        calculatedSalary -= employee.vacation * 100
    }

    if (employee.overtime > 0) {
        calculatedSalary += employee.overtime * 80
    }
    return calculatedSalary
}

here we define an Employee entity, and we have a function to calculate the employee's net salary,

inside the calculateEmployeeNetSalary we have some business rules and deductions to be applied to the employee salary, these rules are in if/else condition form, one for each rule or deduction condition, take a minute to think about the problem in this logic.

hint: what if I need to deduct taxes from the salary?

what if I need to apply a new deduction or rule upon salary, in the above scenario, I have to modify the calculateEmployeeNetSalary by adding the new condition/deduction rule…

fun calculateEmployeeNetSalary(employee: Employee): Float {
    var calculatedSalary = employee.grossSalary        
        ...
    if (calculatedSalary > 3000) { <-- new condition added
        calculatedSalary -= calculatedSalary * 0.14f <-- Taxes percentage
    }
    return calculatedSalary
}

What if I need to add a new business rule like applying an annual increase based on Employee level?

let’s refactor this code to accept new requirements without code changes 🚀…

First, abstract Details (e.g. Salary rules):

interface SalaryTransformer {
    fun transform(employee: Employee, salary: Float): Float
}

now we can describe a salary rule/deduction as it is a salary transform, and each transformation rule is a subtype of SalaryTransformer that describes the computation applied to the employee salary.

class VacationSalaryTransformer: SalaryTransformer {
    override fun transform(employee: Employee, salary: Float): Float {
        return salary - (employee.vacation * 100)
    }
}

class OvertimeSalaryTransformer: SalaryTransformer {
    override fun transform(employee: Employee, salary: Float): Float {
        return employee.overtime * 80 + salary
    }
}

class TaxSalaryTransformer: SalaryTransformer {
    override fun transform(employee: Employee, salary: Float): Float {
        return salary - (salary * 0.14f)
    }
}

Salary transformers to be applied to employee salary based on employee data like vacation, overtime Taxes percentage, etc.

then we can calculate the net salary for an employee like below:

fun calculateEmployeeNetSalary(employee: Employee, salaryTransformers: List<SalaryTransformer>): Float {
    var calculateNatSalary = employee.grossSalary
    for (transformer in salaryTransformers) {
        calculateNatSalary = transformer.transform(employee, calculateNatSalary)
    }
    return calculateNatSalary
}

fun main() {
    val officeBoy = Employee(grossSalary = 4000f, shift = "Morning", vacation = 5, overtime = 15f)
    val salaryTransformers = listOf(
        VacationSalaryTransformer(),
        OvertimeSalaryTransformer(),
        TaxSalaryTransformer()
    )
    val calculatedNetSalary = calculateEmployeeNetSalary(officeBoy, salaryTransformers)
    println(calculatedNetSalary)
}

take a minute and think about the question mentioned above:

What if I need to add a new business rule like applying an annual increase?

Then I need to create a new transformer subtype to describe the new requirement…

class AnnualIncreaseSalaryTransformer (private val annualIncrease: Float): SalaryTransformer {
    override fun transform(employee: Employee, salary: Float): Float {
        return salary + (salary * annualIncrease)
    }
}
// There are no changes here -> Closed for modification :D
fun calculateEmployeeNetSalary(...): Float {...}

fun main() {
    val officeBoy = Employee(grossSalary = 4000f, shift = "Morning", vacation = 5, overtime = 15f, 1)
    val salaryTransformers = listOf(
        VacationSalaryTransformer(),
        OvertimeSalaryTransformer(),
        TaxSalaryTransformer(),
        AnnualIncreaseSalaryTransformer( <-- new business requirement added here
            getAnnualIncreasePercentageFor(officeBoy) <-- utility function
        )
    )
    val calculatedNetSalary = calculateEmployeeNetSalary(officeBoy, salaryTransformers)
    println(calculatedNetSalary)
}

That’s it, all we have to do is add the new requirement code, no changes were made to a working code like calculateEmployeeNetSalary function.

This coding style adheres to the Open/Close Principle 👍🏼

Wrap up

There is no software closed for modification 100%, for example, what if we need to add new functionality in the transformer interface, it depends on the software architect/designer's experience, to group all the functionality for an entity from the beginning.

what you need to do, in case you found a violation, try as much as you can to obey OCP and other SOLID principles, to make it easy for any new requirement in the near future.

you can tell that a unit of code is violating the Open/Close Principle by these Signs:

  • When you find conditions based on data inside a function that determines the logic flow, here you need to inspect for OCP violation.
  • If you create new objects based on conditions or type matching, here you need to inspect for OCP violation.

in real-life scenarios, the, if/else or switch statement, will be more complex and nasty than the above examples, so put the effort and you will gain the fruits later on.

Consider the following points to follow the Open/Close Principle:

Encapsulation: make your variables private

  • By declaring a class variable as public, every function that depends on it will be affected if you change it, if the variable is public, and got changed unexpectedly from another place, it is not safe to depend on it, right?.
  • If your variables were accessible from outside, then you decided to encapsulate and turn them to be private members, what you will face, is cascading changes all over the places where you were depending upon these public members.

Delegation: delegate the details to be specified from outside as much as you can:

  • Delegate the salary Transformers to be specified by the caller, as a list of salary transformers, so adding a new requirement will be as simple as creating a new object.
  • Delegate the data that you depend on it inside a method, to be passed from outside.

Outside: means to be injected through the constructor, or method parameters.

Your turn:

  • Refactor the previous example to accept values like (taxes percentage, vacation rate, etc) to be specified from the outside, as I have done in (AnnualInceaseSalaryTransformer)
  • Find in your projects similar violations and figure out how to adhere Open/Close principle.
  • Read this paper written by Uncle bob if you have the time Link

Conclusion

  • We have learned what is an Open/Close principle, why we need to follow it, and what are problems could happen when a new requirement needs to be added.
  • We saw a couple of examples to explain how we can refactor a bad coding style to a more flexible coding style by following a simple principle.
  • Refactor these examples to follow the Open/Close Principle as a practice

Peace ✌🏼