In Software development, writing clean, maintainable, and extensible code is crucial for long-term success. The SOLID principles provide a set of guidelines that help developers achieve these goals. In this blog post, we will explore the SOLID principles and demonstrate how they can be applied in Swift, one of the most popular programming languages.

SOLID represents 5 principles of object-oriented programming:

  1. Single Responsibility Principle (SRP):
    The SRP states that a class should have only one reason to change, meaning it should have a single responsibility. By keeping classes focused on a specific task, we achieve higher cohesion and reduce the potential for code duplication or bloated classes. This principle helps us to keep our classes as clean as possible.
class UserManager {
     func addUser(_ user: User) {
          // Code to add a user to the system
}

     func removeUser(_ user: User) {
          // Code to remove a user from the system
     }
}

class UserViewController {
     let userManager = UserManager()

     func addUserButtonTapped() {
          let user = User(name: "Ankit", age: 25)
          userManager.addUser(user)
     }

     func removeUserButtonTapped() {
          let user = User(name: "Ankit", age: 25)
          userManager.removeUser(user)
     }
}

In the above example, we have separated the responsibilities of managing users and handling user interface interactions. The UserManager class handles user-related operations, while the UserViewController focuses solely on UI interactions.

  1. Open-Closed Principle (OCP):
    The OCP states that software entities should be open for extension but closed for modification. This principle encourages the use of abstraction and polymorphism, allowing new functionality to be added without modifying existing code.
protocol Shape {
     func area() -> Double
}

class Circle: Shape {
     let radius: Double

     init(radius: Double) {
          self.radius = radius
     }

     func area() -> Double {
          return Double.pi * radius * radius
     }
}

class Rectangle: Shape {
     let width: Double
     let height: Double

     init(width: Double, height: Double) {
          self.width = width
          self.height = height
     }

     func area() -> Double {
          return width * height
     }
}

In this example, the Shape protocol represents a contract for shapes. The Circle and Rectangle classes conform to this protocol and provide their own implementation of the area() method. With this design, we can easily introduce new shapes by creating new classes that adhere to the Shape protocol, without modifying the existing code.

  1. Liskov Substitution Principle (LSP):
    The LSP states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In other words, the subclasses should be able to substitute their parent classes without causing unexpected behavior.

This principle can help you to use inheritance without messing it up.

class Bird {
     func fly() {
          // Code for flying
     }
}

class Ostrich: Bird {
     override func fly() {
          // Ostriches cannot fly, so this method is overridden to provide a different behavior
     }
}

In this example, the Ostrich class inherits from the Bird class but overrides the fly() method to reflect the fact that ostriches cannot fly. Despite this difference, we can still use an Ostrich object wherever a Bird object is expected, maintaining the behavior expected from the superclass.

  1. Interface Segregation Principle (ISP):
    The ISP states that clients should not be forced to depend on interfaces they do not use. It promotes the idea of creating focused, cohesive interfaces, tailored to the specific needs of clients, to avoid unnecessary dependencies.
protocol Printer {
     func printDocument(_ document: Document)
}

protocol Scanner {
     func scanDocument() -> Document
}

class AllInOnePrinter: Printer, Scanner {
     func printDocument(_ document: Document) {
          // Code to print the document
     }

func scanDocument() -> Document {
     // Code to scan and return a document
     }
}

class SimplePrinter: Printer {
    func printDocument(_ document: Document) {
          // Code to print the document
     }
}

In this example, we have separate interfaces for printing and scanning. The AllInOnePrinter class implements both interfaces since it supports both functionalities. On the other hand, the SimplePrinter class only implements the Printer interface, as it does not support scanning. This way, clients can depend on the specific interfaces they require, reducing unnecessary dependencies.

  1. Dependency Inversion Principle (DIP):
    The DIP states that high-level modules should not depend on low-level modules; both should depend on abstractions. This principle encourages decoupling and promotes the use of interfaces or protocols to define contracts between components.
protocol Database {
     func saveData(_ data: Data)
}

class DataManager {
     let database: Database

     init(database: Database) {
          self.database = database
     }

     func save(data: Data) {
          database.saveData(data)
     }
}

In this example, the DataManager class depends on the Database protocol rather than a concrete implementation. This allows us to swap different database implementations (e.g., SQLite, CoreData) without modifying the DataManager class. By relying on abstractions, we achieve loose coupling and improve the flexibility and testability of the codebase.

Conclusion:

The SOLID principles in Swift projects, we can write code that is easier to understand, maintain, and extend. The examples provided in this blog post showcase how each principle can be applied in Swift, but it’s important to remember that these principles are not strict rules to be followed blindly. They are guidelines that help guide software design decisions and foster code that is robust, flexible, and adaptable to change.

Vishal Sharma

Vishal Sharma

Senior Technical Manager - Digital Financial Solutions at Comviva