SOLID principles for object oriented design

 

Today looking at any programmer’s life, anyone can say that “Change is the only constant”, and his life is so boring doing repeated work.

but is that true? Aren’t we gonna apply our intelligence to solve this change problem?

Let’s try it!

Overview

SOLID is an acronym named by Robert C. Martin (Uncle Bob). It represents 5 principles of object-oriented programming:

  1. Single responsibility, 
  2. Open/Closed,
  3. Liskov Substitution,
  4. Interface Segregation and
  5. Dependency Inversion.

You can solve the main problems of a bad architecture using SOLID:

  • Fragility: A change may break unexpected parts—it is very difficult to detect if you don’t have a good test coverage.
  • Immobility: A component is difficult to reuse in another project—or in multiple places of the same project—because it has too many coupled dependencies.
  • Rigidity: A change requires a lot of efforts because affects several parts of the project.

1. S- The Single Responsibility Principle (SRP)
“THERE SHOULD NEVER BE MORE THAN ONE REASON FOR A CLASS TO CHANGE.”

As name suggest, every time you create/change a class, you should ask yourself: How many responsibilities does this class have?

Let’s see an example:

class Handler{
 
    func handle(){
        let data = requestDataToSomeAPI()
        letarray=parse(data:data)
        saveToDB(array:array)
    }
 
    private func requestDataToSomeAPI()->Data{
        // send API request and wait the response
    }
 
    private func parse(data:Data)->[String]{
        // parse the data and create the array
    }
 
    private func saveToDB(array:[String]){
        // save the array in a database (CoreData/Realm/...)
    }
}

How many responsibilities does this class have?

Handler retrieves the data from the API (1), parses the API response, creating an array of String, (2) and saves the array in a database (3).

Once you get to know that you have to use AFNetworking, ObjectMapper, CoreData within the same class for respective responsibility, you will start understanding the smell of this class

Let’s solve this problem by moving responsibilities down to little classes

 
Class Handler {
 
    let apiHandler:APIHandler
    let parseHandler:ParseHandler
    let dbHandler:DBHandler
 
    init(apiHandler:APIHandler,parseHandler:ParseHandler,dbHandler:DBHandler){
        self.apiHandler=apiHandler
        self.parseHandler=parseHandler
        self.dbHandler=dbHandler
    }
 
    func handle(){
        letdata=apiHandler.requestDataToAPI()
        letarray=parseHandler.parse(data:data)
        dbHandler.saveToDB(array:array)
    }
}
 
class APIHandler{
 
    func requestDataToAPI()->Data{
        // send API request and wait the response
    }
}
 
class ParseHandler{
 
    func parse(data:Data)->[String]{
        // parse the data and create the array
    }
}
 
class DBHandler{
 
    func saveToDB(array:[String]){
        // save the array in a database (CoreData/Realm/...)
    }
}
 
Benefits:
  1. Keep your classes as clean as possible
  2.  Now you can write tests for individual classes APIHandler, ParseHandler,  DBHandler which wasn’t possible earlier because of private methods in Handler class.

2.  O – The Open-Closed Principle (OCP)
    “SOFTWARE ENTITIES (CLASSES, MODULES, FUNCTIONS, ETC.) SHOULD BE OPEN FOR EXTENSION, BUT CLOSED FOR MODIFICATION.”

If you want to create a class easy to maintain, it must have two important characteristics:

  • Open for extension: You should be able to extend or change the behaviours of a class without efforts.
  • Closed for modification: You must extend a class without changing the implementation.

You can achieve these characteristics using abstraction.

As example, we have a class Filter which iterates an array of Product and returns filtered array

class Filter{
 
    Static func filterArray(array:[Product],name:String)->[Product]{
        var filteredArray = [Product]()
        // filtering logic
        for product in array {
                        if product.filterUsing(name:String) {
                               filteredArray.append(product)
                        }
        }
        return  filteredArray  
    }
}
 
class Product{
    let name:String
    let color:String
 
    init(name:String,color:String){
        self.name=name
        self.color=color
    }
 
    func filterUsing(name:String)->Bool{
 
        if name == self.name {
          return true
        }
        return false
    }
}
 
 

If you want to add similar functionality for other class like Train, we need to change implementation of Filter class to support filtering Train data. – breaking OCP.

class Filter{
 
    Static func filterArray(array:[Product],name:String)->[Product] {
        var filteredArray = [Product]()
        // filtering logic
        for product in array {
                        if product.filterUsing(name:String) {
                               filteredArray.append(product)
                        }
        }
        return  filteredArray  
    }
 
    Static func filterArray(array:[Train],name:String)->[Train] {
        var filteredArray = [Train]()
        // filtering logic
        for train in array {
                        if train.filterUsing(name:String) {
                               filteredArray.append(product)
                        }
        }
        return  filteredArray  
    }

}

 
classProduct{
    letname:String
    letcolor:String
 
    init(name:String,color:String){
        self.name=name
        self.color=color
    }
 
    funcfilterUsing(name:String)->Bool{
 
             if name == self.name {
 
            return true
       }
       return false
    }
}
 
class Train {
    letname:String
    letschedue:Any
 
    init(name:String,color:String){
        self.name=name
        self.color=color
    }
 
    funcfilterUsing(name:String)->Bool{
 
       if name == self.name {
 
            return true
       }
       return false
    }
}
 

We can solve this problem creating a new protocol Filterable, which will be implemented by the classes to be filtered. Finally, filterArray will filter an array of Filterable.

In this way, we create a new abstract layer between filterArray and the classes to be filtered, allowing the filter of other classes like Train and without changing the filterArray implementation.

protocol Filterable : NSObjectProtocol{
    func filterUsing(name:String)->Bool
}
class Filter{
    Static func filterArray(array:[Filterable],name:String)->[Filterable] {
        var filteredArray = [Filterable]()
        // filtering logic
        for obj in array {
                        if obj.filterUsing(name:String) {
                               filteredArray.append(obj)
                        }
        }
        return  filteredArray  
    }

}

 
class Product : Filterable{
    let name:String
    let color:String
 
    init(name:String,color:String){
        self.name=name
        self.color=color
    }
 
    func filterUsing(name:String)->Bool{
 
             if name == self.name {
 
            return true
       }
       return false
    }
}
 
class Train : Filterable {
    let name:String
    let schedue:Any
    init(name:String,color:String){
        self.name=name
        self.color=color
    }
 
    func filterUsing(name:String)->Bool{
       if name == self.name {
             return true
       }
       return false
    }

Benefits:

  • Without touching Filter class, tomorrow you can still add filter functionality to other classes.
  • Now Filter is no more tightly coupled with data type, you can write tests easily.
     

     
    3. L – The Liskov Substitution Principle (LSP)
“FUNCTIONS THAT USE POINTERS OR REFERENCES TO BASE CLASSES MUST BE ABLE TO USE OBJECTS OF DERIVED CLASSES WITHOUT KNOWING IT.”

If you use inheritance in an improper way, It could be very dangerous.

This principle can help you to use inheritance without messing it up. Let’s see the main problems which break LSP:

Preconditions changes

We have a class CloudHandler,

Responsibility  – To save a string in a Cloud service.

Business logic change – you must sometimes save the string just if its length is greater than 5.

Therefore, we decide to create a subclass FilteredCloudHandler:

class CloudHandler{ 
    func save(string:String) {     

   // Save string in the Cloud   

 }


class FilteredCloudHandler:Handler { 
    override func save(string:String) { 

       guard string.characters.count > 5 else { return } 
        super.save(string:string)    

}

This example breaks LSP because, in the subclass, we add the precondition that string must have a length greater than 5. A client of Handler doesn’t expect that FilteredCloudHandler has a different precondition, since it should be the same for CloudHandler and all its subclasses.

Meaning – we should not change the base functionality behaviour in subclass without acknowledging to base class

We can solve this problem getting rid of FilteredCloudHandler and adding a new optional parameter to inject the minimum length of characters to filter:

class CloudHandler { 
    func save(string:String, minChars:Int=0) {     

   guard string.characters.count >= minChars else { return } 
        // Save string in the Cloud    

}

}  

Postconditions changes

We have a class Rectangle which compute the area of some rectangle objects. After a couple of months, we need to compute also the area of square objects, so we decide to create a subclass Square.

As we don’t want to override area method , we decide to assign the same value of width to length:

 class Rectangle{ 
    var width:Float=0   

    var length:Float=0 
    var area:Float { 

       return width*length  

  }


class Square:Rectangle { 
    override var width:Float {

        didSet {            

             length=width

        }   

 }

With this approach, we break LSP because if the client has the current method:

func printArea(of rectangle:Rectangle) {

    rectangle.length = 5

    rectangle.width = 2

    print(rectangle.area)

the result should always be the same in the both calls:

let rectangle = Rectangle()

printArea(of:rectangle)// 10 
// ------------------------------- 
let square=Square()

printArea(of:square)// 4 

Instead, the first one prints 10 and the second one 4. This means that, with this inheritance, we have just broken the postcondition of the width setter which is: ((width == newValue) && (height == height)).

We can solve it using a protocol with a method area, implemented by Rectangle and Square in different ways. Finally, we change the printArea parameter type to accept an object which implement this protocol:

protocol Polygon {    

var area:Float {

get

}


class Rectangle:Polygon { 
    private let width:Float    

private let length:Float 
    init(width:Float,length:Float){        

self.width=width        

self.length=length

    } 
    var area:Float {

        return width*length

    }


class Square:Polygon { 
    private let side:Float 
    init(side:Float){

        self.side = side 

   } 
    var area:Float{        

        return pow(side,2)

    }


// Client Method 
func printArea(of polygon:Polygon)

{    

    print(polygon.area)


// Usage 
let rectangle = Rectangle(width:2,length:5)

printArea(of:rectangle)// 10 
let square=Square(side:2)

printArea(of:square)// 4 


4.  I – The Interface Segregation Principle (ISP)
“CLIENTS SHOULD NOT BE FORCED TO DEPEND UPON INTERFACES THAT THEY DO NOT USE.”
 

This principle can solve one of the problems of object-oriented programming which is the fat interface.

( A interface is called “fat” when has too many members/methods, which are not cohesive and contains more informations than needed. This problem can affect both classes and protocols)

Fat interface (Protocol)

We start with the protocol GestureProtocol with a method didTap:

protocol GestureProtocol{
  func didTap()
}

After some time, you have to add new gestures to the protocol and it becomes:

protocol GestureProtocol{
    func didTap()
    func didDoubleTap()
    func didLongPress()
}

Our NiceButton is happy to implement the methods which it needs:

class NiceButton:GestureProtocol{
    func didTap(){
        // send tap action
    }
 
    func didDoubleTap(){
        // send double tap action
    }
 
    func didLongPress(){
        // send long press action
    }
}
 

The problem is that our app has also a GoodButton which needs just didTap. It must implement methods which it doesn’t need, breaking ISP:

class GoodButton:GestureProtocol{
    func didTap() {
        // send tap action
    }
 
    func didDoubleTap() {}
 
    func didLongPress() {}
}

We can solve the problem using little protocols instead of a big one:

protocol TapProtocol{
    func didTap()
}
 
protocol DoubleTapProtocol{
    func didDoubleTap()
}
 
protocol LongPressProtocol{
    func didLongPress()
}
 
class NiceButton:TapProtocol,DoubleTapProtocol,LongPressProtocol{
    func didTap(){
        // send tap action
    }
 
    func didDoubleTap(){
        // send double tap action
    }
 
    func didLongPress(){
        // send long press action
    }
}
 
class GoodButton:TapProtocol{
    func didTap(){
        // send tap action
    }
}
 
Fat interface (Class)

Let’s say, we have an application which has a collection of supported Payments. This app has the class Payment which represents a payment :

 

class Payment{
    var methodName:String=“CreditCard"
    var description:String=“Credit Card payment”
    var redirectionURL:URL=URL(“http:someredirection.com/something")
    var paymentDate:Date=Date()
}

And we inject it in the pay API service:

func pay(payment:Payment){
//    Update payment on server
}

Unfortunately, we are injecting too many informations in the method pay, since it needs just redirectionUrlmethodName.

You can solve this problem using a protocol Payable with just the informations which the API needs:

protocol Payable{
    var methodName: String { get }
    var redirectionURL: String { get }

 }

class Payment:Playable{
 
    var methodName:String=“CreditCard"
    var description:String=“Credit Card payment”
    var redirectionURL:String=“http:someredirection.com/something"
    var paymentDate:Date=Date()
}
 
func play(payment:Playable){
    // update payment on server
}
 
This approach is very useful also for the unit test. We can create a stub class which implements the protocol Payable:
class StubPayable:Payable{
    private (set) var isMethodNameRead=false
 
    var methodName:String{
        self.isMethodNameRead=true
        return“CreditCard"
    }
 
    var redirectionURL:String=“https:someurl.com/something"
}
 
func test_Pay_IsMethodNameRead(){
    let stub=StubPayable()
 
    pay(payment:stub)
 
    XCTAssertTrue(stub.isMethodNameRead)
}

 


5. The Dependency Inversion Principle (DIP)
“A. HIGH LEVEL MODULES SHOULD NOT DEPEND UPON LOW LEVEL MODULES. BOTH SHOULD DEPEND UPON ABSTRACTIONS.”
“B. ABSTRACTIONS SHOULD NOT DEPEND UPON DETAILS. DETAILS SHOULD DEPEND UPON ABSTRACTIONS.”

This principle is the right one to follow if you believe in reusable components.

DIP is very similar to Open-Closed Principle: the approach to use, to have a clean architecture, is decoupling the dependencies. You can achieve it.

Let’s consider the class StorageManager, which saves a string in the filesystem. It calls, internally, FileSystemManager which manages to save the string in the filesystem:

Conclusion

If you follow SOLID principles properly, you can not only increase the quality of your code but also can create more maintainable and reusable components which is the key point to defeat CHANGE.

There will be different kinds of problems in your project, ultimately you need to verify your design against SOLID check if it’s not breaking any principle,

SOLID principles will help you solve Fragility, Immobility and Rigidity… !

Let them say “Change is constant in your life” but you be SOLID, Enjoy coding …. 🙂

Note:

  • Examples quoted in this article are written in Swift
Share