Who made the SOLID principles?

The principles are a subset of many principles promoted by American software engineer and instructor Robert C. Martin, first introduced in his 2000 paper Design Principles and Design Patterns discussing software rot.

Robert C. Martin is also the author of bestselling books Clean Code and Clean Architecture, and is one of the participants of the “Agile Alliance”.

What are SOLID principles?

SOLID principles are the set of software design principles used by software engineers in object-oriented software development to follow, scale and maintain a proper structure to the codes & programming. As successful software changes, it becomes increasingly complex. Software becomes rigid, fragile, immobile, and viscous without good design principles. The SOLID principles were developed to combat these problematic design patterns.

What is the importance of SOLID principles?

These five principles help us understand the need for certain design patterns and software architecture in general. SOLID principles aim at reducing the dependencies of software engineers. As if they know and understand the basic architecture & design principles, it’ll be easier for them to change or edit codes and alter a few changes if required.

Additionally, SOLID principles enable engineers to make designs easier to understand, maintain, and extend. Ultimately, using these design principles makes it easier for software engineers to avoid issues and to build adaptive, effective, and agile software.

Where are SOLID principles used?

These principles are used in object-oriented programming and software design

What does the SOLID acronym stand for?

  • The Single Responsibility Principle
  • The Open-Closed Principle
  • The Liskov Substitution Principle
  • The Interface Segregation Principle
  • The Dependency Inversion Principle

What is the Single Responsibility Principle?

It states that a class should do one thing and therefore it should have only a single reason to change. If a Class has many responsibilities, it increases the possibility of bugs, because making changes to one of its responsibilities, could affect the other ones without you knowing. One potential change around database logic, logging logic, or attribute can affect the objects of a specific class.

This means that if a class is a data container, like an Animal class or an Employee class, and it has some fields regarding that entity, it should change only when we change the data model. Generally, many teams work on the same project and edit the same class for different reasons, which could lead to incompatible modules and glitches. Hence, this principle focuses on assigning or adding single functions to class & objects.

Let’s have an example:

class BadHandler {
    
    func handle() {
        let data = requestDataToAPI()
        let array = parseResponse(data: data)
        saveToDatabase(array: array)
    }
  
    private func requestDataToAPI() -> Data {
        // Network request and wait the response
    }
    
    private func parseResponse(data: Data) -> [String] {
        // Parse the network response into array
    }
   
    private func saveToDatabase(array: [String]) {
        // Save parsed response into database
    }
}

from an above example, BadHandler class perform multiple responsibilities like making a network call, parsing the response and saving into the database. You can solve this problem moving the responsibilities down to little classes.

protocol APIHandler {
    func requestDataToAPI() -> Data
}

protocol ParseHandler {
    func parseResponse(data: Data) -> [String]
}

protocol DBHandler {
    func saveToDatabase(array: [String])
}

final class Handler {
    let apiHandler: APIHandler
    let parseHandler: ParseHandler
    let databaseHandler: DBHandler
    
    init(apiHandler: APIHandler, parseHandler: ParseHandler, databaseHandler: DBHandler) {
        self.apiHandler = apiHandler
        self.parseHandler = parseHandler
        self.databaseHandler = databaseHandler
    }
    
    func handle() {
        let data = apiHandler.requestDataToAPI()
        let array = parseHandler.parseResponse(data: data)
        databaseHandler.saveToDatabase(array: array)
    }
}

class NetworkHandler {
    func requestDataToAPI() -> Data {
        // Network request and wait the response
    }
}
class ResponseHandler {
    func parseResponse(data: Data) -> [String] {
        // Parse the network response into array
    }
}
class DatabaseHandler {
    func saveToDatabase(array: [String]) {
        // Save parsed response into database
    }
}

What is the Open-Closed Principle?

The Open-Closed Principle suggests that classes should be open for extension and closed to modification. Sometimes, we need to add certain functions to the existing class to perform additional tasks.

So according to the Open-Closed Principle, We should add new functionality without touching the existing code for the class. This is because whenever we modify the existing code, we risk creating potential bugs. So we should avoid touching the tested and reliable (mostly) production code if possible. In simple words, this principle aims to extend a Class’s behavior without changing the existing behavior of that Class.

Here’s an example for a bad code:

class PaymentManager {
    func makeCashPayment(amount: Double) {
        //perform
    }

    func makeVisaPayment(amount: Double) {
        //perform
    }
}

Let’s imagine we have a PaymentManager. Let this manager support cash and Visa payment methods in the first stage. So far everything is great. After a while, we had to update the manager and we are expected to add MasterCard feature as a new feature.

Let’s create a function called makeMasterCardPayment like the previous functions. Great, our code will continue to work. We complied with the requirements but we broke a rule that the class must be closed for modification. For a task that does a similar job, we shouldn’t add anything new to the class.

class PaymentManager {
    func makeCashPayment(amount: Double) {
        //perform
    }

    func makeVisaPayment(amount: Double) {
        //perform
    }
    
    // v2
    func makeMasterCardPayment(amount: Double) {
        //perform
    }
}

We can solve this problem by the following way:

protocol PaymentProtocol {
    func makePayment(amount: Double)
}

// v1 features
class CashPayment: PaymentProtocol {
    func makePayment(amount: Double) {
        //perform
    }
}

class VisaPayment: PaymentProtocol {
    func makePayment(amount: Double) {
        //perform
    }
}

//v2 features
class MasterCardPayment: PaymentProtocol {
    func makePayment(amount: Double) {
        //perform
    }
}

final class PaymentManager {
    func makePayment(amount: Double, using payment: PaymentProtocol) {
        payment.makePayment(amount: amount)
    }
}

What is the Liskov Substitution Principle?

The Liskov Substitution Principle works on the Inheritance mechanism, where a subclass inherits the features (fields and methods) of the parent class. As it’s very important in Object-Oriented Programming to allow the reusability factor from the functionality perspective. The Liskov Substitution Principle states that subclasses should be substitutable for their base classes.

Suppose we have a class of rectangles, the rectangles have a width and a height, and their product is equal to the area.

Whether we have a square class, theoretically a square is a rectangle, so we can inherit the class square from the class rectangle. So far everything is great.

The following setSizeAndPrint function expects a rectangle type variable and assigns the rectangle width and height by default. It’s okay to call this function for the rectangle class, because width = 4height = 5area = 20.

But the same is not true for a square that inherits from the rectangle class because the two sides of a square are equal. We can’t just assign 4 and 5 by default and expect it to behave like the class it inherits.

At this point, classes that can’t act as inherited classes and need situation-specific development breaks the LSP.

class Rectangle {

    var witdh: Float = 0
    var height: Float = 0

    func set(witdh: Float) {
        self.witdh = witdh
    }

    func set(height: Float) {
        self.height = height
    }

    func calculateArea() -> Float {
        return witdh * height
    }

}

class Square: Rectangle {

    override func set(witdh: Float) {
        self.witdh = witdh
        self.height = witdh
    }

    override func set(height: Float) {
        self.height = height
        self.witdh = witdh
    }
}

//breaks the lsp
func setSizeAndPrint(of rectangle: Rectangle) {
    rectangle.set(height: 5)
    rectangle.set(witdh: 4)
    print(rectangle.calculateArea())
}

func example() {
    let rectangle = Rectangle()
    setSizeAndPrint(of: rectangle)

    let square = Square()
    setSizeAndPrint(of: square)
}

As a solution, it is aimed for each class to perform its own tasks within itself, by keeping the common tasks between classes in a certain abstract structure (protocol).

As in the example above, the common task between the Rectangle and Square classes is to calculate the area of the object. Both the rectangle and square classes inherit the Polygon abstract structure after this task is defined in a common protocol. Thus, each class fulfills the necessary tasks within itself and there is no need to make any special developments. Classes behave just like the structure they inherit.

protocol Polygon {
    func calculateAre() -> Float
}

class Rectangle: Polygon {

    var witdh: Float = 0
    var height: Float = 0

    func set(witdh: Float) {
        self.witdh = witdh
    }

    func set(height: Float) {
        self.height = height
    }

    func calculateAre() -> Float {
        return witdh * height
    }
}

class Square: Polygon {

    var side: Float = 0

    func set(side: Float) {
        self.side = side
    }

    func calculateAre() -> Float {
        return pow(side,2)
    }
}

func printArea(polygon: Polygon) {
    print(polygon.calculateAre())
}

func example() {
    let rectangle = Rectangle()
    rectangle.set(witdh: 4)
    rectangle.set(height: 5)
    print(printArea(polygon: rectangle))

    let square = Square()
    square.set(side: 4)
    printArea(polygon: square)
}

What is the Interface Segregation Principle?

Clients should not be forced to depend on methods that they do not use. Because, when a Class is required to perform actions that are not useful, it is wasteful and may produce unexpected bugs if the Class cannot perform those actions.

It is advisable for software engineers to start by building a new interface and then let the class implement multiple interfaces as needed rather than using an existing interface and adding new methods.

For example:

protocol Worker {
    func eat()
    func work()
}

class Human: Worker {
    func eat() {
        print("eating")
    }

    func work() {
        print("working")
    }
}

class Robot: Worker {
    func eat() {
        // Robots can't eat!
        fatalError("Robots does not eat!")
    }

    func work() {
        print("working")
    }
}

As shown above a robot can’t eat, so to fix that we must divide our responsibilities into basic parts.

We are creating a new abstract structure called Feedable for the eat function, and the Workable abstract structure for the work function. Thus, we have divided our responsibilities.

Now the Human class will inherit from Feeble and Workable, and the Robotclass from Workable only.

protocol Feedable {
    func eat()
}

protocol Workable {
    func work()
}

class Human: Feedable, Workable {
    func eat() {
        print("eating")
    }

    func work() {
        print("working")
    }
}

class Robot: Workable {
    func work() {
        print("working")
    }
}

What is the Dependency Inversion Principle?

The DIP states that high-level modules should not depend on low-level modules; both should depend on abstractions. It encourages the use of interfaces or abstract classes to decouple components and improve modularity. By relying on abstractions, it becomes easier to replace implementations and make the system more flexible.

Example:

struct Employee {
    func work() {
        print("working...")
    }
}

struct Employer {
    var employees: [Employee]

    func manage() {
        employees.forEach { employee in
            employee.work()
        }
    }
}

func run() {
    let employer = Employer(employees: [Employee()])
    employer.manage()
}

We have an employee structure and this structure has a work function. We also have an Employer structure and this structure enables existing employees to work.

An employer object is created in the run function and by default, it takes the array Employee. Again, everything is fine so far, probably our project will work, but there is something we missed here. The Employer structure is directly linked to the non-abstract Employee structure. This is the point where we need the DIP.

protocol Workable {
    func work()
}

struct Employee: Workable {
    func work() {
        print("working...")
    }
}

struct Employer {
    var workables: [Workable]

    func manage() {
        workables.forEach { workable in
            workable.work()
        }
    }
}

func example() {
    let employer = Employer(workables: [Employee()])
    employer.manage()
}

Using the theoretical knowledge of DIP, we know that structures should depend on an abstract model.

So, we created an abstract Workable structure and depend the Employee class to Workable so that the Employee structure retains its original functions.

The point is that the Employer class now expects the array of the abstract struct Workable instead of the array Employee. Thus, we have linked the dependency of the Employer structure to an abstract module. This means that the Employer structure has come to the point where it can run any structure depend to the Workable module.

Resources