Select Page

SOLID Principles: A Comprehensive Guide to Object-Oriented Class Design Using Swift

Jun 13, 2023

Do you ever wonder how developers write scalable, maintainable, and well-organized code? 

Simple. With Object-Oriented Design.  

Object-Oriented Programming (OOP) is a widely used programming paradigm that allows developers to create flexible, maintainable, and reusable code. And for a good object-oriented design in programming, the SOLID principles are a must-follow. 

SOLID principles are a set of guidelines that enable developers to design effective class structures and improve their OOP skills.  

In this comprehensive guide, our in-house Software Developer, Manjunathan Karuppasamy explains SOLID principles in depth and demonstrates how to apply them in real-world scenarios. 

Now, let’s begin!

Introduction to SOLID Principles 

The SOLID principles are a set of five fundamental concepts in object-oriented class design that help developers create robust, maintainable, and modular code. These principles were first introduced by esteemed computer scientist Robert C. Martin (also known as Uncle Bob) in the year 2000. Michael Feathers later coined the acronym SOLID to represent these principles.  

The SOLID principles include: 

S: Single Responsibility Principle (SRP)  

O: Open-Closed Principle (OCP)  

L: Liskov Substitution Principle (LSP)  

I: Interface Segregation Principle (ISP)  

D: Dependency Inversion Principle (DIP)  

Why use SOLID Principles? 

By following SOLID principles, software developers can write better, cleaner, and more flexible code, ultimately increasing the overall quality of the software. This can lead to a number of benefits that include the following. 

  • Increased code readability and maintainability  
  • Reduced risk of bugs  
  • Improved ability to add new features  
  • Increased flexibility and adaptability  
  • Improved testability 

1. Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have a single reason to change, meaning it should focus on a single task or functionality. This principle promotes separation of concerns, making it easier to understand, maintain, and expand upon the code.  

To apply the SRP, it is important to identify the different responsibilities that a class may have and separate them into distinct classes. For example, if a class is responsible for both data processing and data storage, these tasks should be divided into separate classes. This separation of concerns will prevent potential conflicts, simplify version control, and reduce the likelihood of merge conflicts. 

Here is an example of SRP for you.  

class APIHandlerWithoutSRP { 

func apiHandle() { 

let data = requestData() 

let array = parse(data: data) 

saveToDatabase(array: array) 

} 

func requestData() -> Data { 

// Network request and wait the response 

return Data() 

} 

func parse(data: Data) -> [String] { 

// Parse the network response into array 

return [String]() 

 

func saveToDatabase(array: [String]) { 

// Save parsed response into database 

} 

} 

requestData() -> Request data from server 

parse() -> Parse the request data to required string array 

saveToDatabase() -> Save the parsed array to local disk 

If a class has one or more responsibilities, it will eventually break the system’s design. That is why SRP recommends using a class with a single responsibility, as demonstrated below. 

class APIHandlerWithSRP {  

let apiHandler: NetworkHandler 

let parseHandler: ResponseHandler 

let databaseHandler: DatabaseHandler 

init(apiHandler: NetworkHandler, parseHandler: ResponseHandler, 

dbHandler: DatabaseHandler) { 

self.apiHandler = apiHandler 

self.parseHandler = parseHandler 

self.databaseHandler = dbHandler 

} 

func handle() { 

let data = apiHandler.requestData() 

let array = parseHandler.parse(data: data) 

databaseHandler.saveToDatabase(array:array) 

} 

} 

class NetworkHandler { 

func requestData() -> Data { 

// Network request and wait the response 

return Data() 

} 

} 

class ResponseHandler { 

func parse(data: Data) -> [String] { 

// Parse the network response into array 

return [String]() 

} 

} 

class DatabaseHandler { 

func saveToDatabase(array: [String]) { 

// Save parsed response into database 

} 

} 

The above example shows a class with an individual purpose and a clean structure.   

 

2. Open-Closed Principle (OCP)

The Open-Closed Principle states that classes should be open for extension but closed for modification. In other words, new functionality should be added through extension (inheritance or composition) rather than by modifying existing code.  

To adhere to the OCP, developers should rely on interfaces or abstract classes when designing their class structures. This approach allows new functionality to be added without modifying the existing code, reducing the risk of introducing bugs and increasing the overall stability of the software.  

Here is a simple example of how to calculate the area for different shapes without OCP. 

class RectangleWithoutOCP { 

var width: Double 

var height: Double 

init(width:Double,height:Double) { 

self.width = width 

self.height = height 

} 

func calculateArea() -> Double { 

return width * height 

} 

} 

class TriangleWithoutOCP { 

var base: Double 

var height: Double 

init(base: Double, height: Double) { 

self.base = base 

self.height = height 

} 

func calculateArea() -> Double { 

return 1/2 * base * height 

} 

} 

class CalculateAreaManagerWithoutOCP { 

func area(shape: RectangleWithoutOCP) -> Double { 

return shape.calculateArea() 

} 

func area(shape: TriangleWithoutOCP) -> Double { 

return shape.calculateArea() 

} 

} 

let manager = CalculateAreaManagerWithoutOCP() 

let rectangleShape = RectangleWithoutOCP(width: 10, height: 5) 

let triangleShape = TriangleWithoutOCP(base: 5, height: 10) 

let areaOfRectangle = manager.area(shape: rectangleShape) 

let areaOfTriangle = manager.area(shape: triangleShape) 

The above scenario does not allow a single function to calculate the area of different shapes. But if we want to calculate the area of different shapes, we should keep adding area() method with the respective arguments. It breaks the system design and creates a mix-up in that class. To overcome this, we can use OCP, as shown below. 

protocol Shape { 

func calculateArea() -> Double 

} 

class Rectangle:Shape { 

var width: Double 

var height: Double 

init(width:Double,height:Double) { 

self.width = width 

self.height = height 

} 

func calculateArea() -> Double { 

return width * height 

} 

} 

class Triangle:Shape { 

var base: Double 

var height: Double 

init(base:Double,height:Double) { 

self.base = base 

self.height = height 

} 

func calculateArea() -> Double { 

return 1/2 * base * height 

} 

} 

class CalculateAreaManager { 

func area(shape: Shape) -> Double{ 

return shape.calculateArea() 

} 

} 

let manager = CalculateAreaManager() 

let rectanglaShape = Rectangle(width: 10, height: 5) 

let triangleShape = Triangle(base: 5, height: 10) 

let areaOfRectangle = manager.area(shape: rectanglaShape) 

let areaOfTriangle = manager.area(shape: triangleShape) 

print(areaOfTriangle) 

print(areaOfRectangle) 

In the above example, the CalculateAreaManager class includes a single method with a generic argument defined as shape, that allows the area of multiple shapes to be calculated. The shape argument is passed with the protocol entity, allowing additional functionality to be added without disrupting the existing system design. 

 

3. Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states that subclasses should be substitutable for their base classes. In essence, if class B is a subclass of class A, objects of class B should be able to replace objects of class A without affecting the correctness of the program. LSP can be used for code reusability, reduced coupling, and easier maintenance.  

To ensure compliance with the LSP, developers should focus on designing subclasses that extend the behavior of their base classes without narrowing it down or altering the expected output. Violations of the LSP can lead to subtle bugs that are difficult to detect and resolve.  

class RectangleWithoutLiskov { 

var width: Double = 0 

var length: Double = 0 

var area: Double { 

return width * length 

} 

} 

class SquareWithoutLiskov: RectangleWithoutLiskov { 

override var width: Double { 

didSet { 

length = width 

} 

} 

override var area: Double { 

return pow(width, 2) 

} 

} 

let rectangleWithoutLiskov = RectangleWithoutLiskov() 

rectangleWithoutLiskov.width = 5 

rectangleWithoutLiskov.length = 5 

rectangleWithoutLiskov.area 

let squareWithoutLiskov = SquareWithoutLiskov() 

squareWithoutLiskov.width = 3 

squareWithoutLiskov.area 

In this case, subclass instances have priority over instances in the superclass. When a change happens in a subclass that does not override/affect a superclass, Liskov substitution is used, as demonstrated below. 

protocol ShapeLiskov { 

var area: Double { get } 

} 

class RectangleLiskov: ShapeLiskov { 

var width: Double = 0 

var length: Double = 0 

var area: Double { 

return width * length 

} 

} 

class SquareLiskov: ShapeLiskov { 

var width: Double = 0 

var area: Double { 

return pow(width, 2) 

} 

} 

let objRectangle = RectangleLiskov() 

objRectangle.length = 5 

objRectangle.width = 5 

objRectangle.area 

let objSquare = SquareLiskov() 

objSquare.width = 6 

objSquare.area 

 

4. Interface Segregation Principle (ISP)

The Interface Segregation Principle states that clients should not be forced to implement interfaces they do not use. In other words, it is better to have multiple smaller, client-specific interfaces rather than one large, general-purpose interface.  

To apply the ISP, it is crucial to identify the various functionalities that a class may need to implement and then segregate them into individual interfaces. By doing so, clients can implement only the interfaces that are relevant to their specific needs, promoting a more organized and modular codebase.  

Here is a simple example without ISP. 

protocol AllRounderWithoutISP { 

func doBatting() 

func doBowling() 

func doFeilding() 

} 

class Batsman: AllRounderWithoutISP { 

func doBatting(){ 

} 

func doFeilding(){ 

} 

func doBowling() { 

} 

} 

class Bowler:AllRounderWithoutISP { 

func doBowling(){ 

} 

func doFeilding(){ 

} 

func doBatting() { 

} 

} 

class AllRounder:AllRounderWithoutISP { 

func doBatting(){ 

} 

func doBowling(){ 

} 

func doFeilding(){ 

} 

} 

class Substitute: AllRounderWithoutISP { 

func doFeilding() { 

} 

func doBatting() { 

} 

func doBowling() { 

} 

} 

In the above scenario, the unusable protocol methods take place in Batsman/Bowler/Substitute class, which makes the class unfit and serves no use for those unused methods. To overcome this problem, we will apply the interface segregation principle. 

As demonstrated below, we use inheritance in protocol to select the required data for each class. 

protocol OnlySubstitude { 

func doFeilding() 

} 

protocol BatsmanOnly: OnlySubstitude { 

func doBatting() 

func doFeilding() 

} 

protocol BowlerOnly: OnlySubstitude { 

func doBowling() 

func doFeilding() 

} 

protocol AllRounderOnly: BatsmanOnly,BowlerOnly { 

func doBatting() 

func doBowling() 

func doFeilding() 

} 

class AllRounderClass: AllRounderOnly { 

func doBatting() {  

} 

func doBowling() { 

} 

func doFeilding() { 

} 

} 

class BatsmanClass: BatsmanOnly { 

func doBatting() { 

} 

func doFeilding() { 

} 

} 

class BowlerClass: BowlerOnly { 

func doBowling() {  

} 

func doFeilding() { 

} 

} 

class SubstituteClass: OnlySubstitude {  

func doFeilding() {  

} 

} 

 

5. Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules, but both should depend on abstractions (i.e., interfaces or abstract classes). This principle promotes a more flexible and maintainable code structure by reducing the coupling between modules.  

To follow the DIP, developers should rely on interfaces or abstract classes when designing the interactions between high-level and low-level modules. This approach allows for easy swapping of dependencies, making the code more adaptable to change and easier to test. 

We need to distinguish between dependency injection and dependency inversion here. In dependency injection, we generate an instance object of a specific class directly, as illustrated below. 

class Developer { 

func doIOS() { 

 

func doAndroid() {  

} 

func doReactNative() { 

} 

} 

let developer = Developer() 

developer.doIOS() 

developer.doAndroid() 

developer.doReactNative() 

In the example above, the ‘developer’ object will have access to all of the data in the Developer() class. If we wish to restrict some entities, you must assign access permissions to each one individually. Otherwise, you will have access to all entities for those with internal access to that class. As a result, anytime a new entity is added, the developer is always aware of its access permissions. It’s a hassle to keep track of each entity’s access permissions in big fat classes. However, when a developer class inherits another class, the superclass methods are overridden, as shown below.  

class AndroidDeveloper: Developer { 

override func doIOS() { 

} 

override func doAndroid() { 

} 

override func doReactNative() { 

} 

} 

To address these concerns, we will use the dependency inversion principle. In dependency inversion, we do not create an instance of the class directly but rather an instance of another object to access the class’s properties, such as protocol. Here’s an example: 

protocol AndroidDevelopement { 

func doAndroid() 

} 

protocol IOSDevelopement { 

func doIOS() 

} 

protocol ReactDevelopemnt { 

func doReactNative() 

} 

class Developement: 

AndroidDevelopement,IOSDevelopement,ReactDevelopemnt { 

func doIOS() { 

} 

func doAndroid() { 

} 

func doReactNative() { 

} 

} 

class AndroidDevelopementOnly:AndroidDevelopement {  

func doAndroid() { 

} 

} 

The fundamental concept here is what has to be shown for another entity (abstraction), and DIP focuses mainly on abstraction support with low coupling. 

SOLID Principles in Action: A Practical Example

To demonstrate the application of SOLID principles, let’s consider a simple inventory management system. Initially, the system may have a single class responsible for managing products, calculating prices, and generating reports. By applying the SOLID principles, we can refactor this class into multiple, smaller classes, each with a specific responsibility:  

  • Product class for managing product data  
  • Price Calculator class for calculating prices  
  • Report Generator class for generating reports  

By separating these responsibilities into distinct classes, we have created a more organized, maintainable, and extensible codebase that adheres to the SOLID principles.  

Common Mistakes and Anti-Patterns

When working with SOLID principles, it is essential to be aware of common mistakes and anti-patterns that can hinder the effectiveness of your code. Some examples include:  

  • Mixing data processing and data storage logic in a single class (violates SRP)  
  • Modifying existing classes instead of extending them (violates OCP)  
  • Creating subclasses that alter the expected output of their base classes (violates LSP)  
  • Forcing clients to implement unnecessary methods (violates ISP)  
  • Depending on concrete classes instead of abstractions (violates DIP)  

To avoid these issues, developers should consistently assess their code for adherence to the SOLID principles and refactor as needed.  

Conclusion

The SOLID principles are a powerful set of guidelines that can help developers create clean, maintainable, and modular code. By understanding and applying these principles, you can elevate your Object-Oriented Programming skills and produce software that is more adaptable to change and easier to collaborate on.   

To further expand your knowledge on SOLID principles and OOP, consider exploring resources such as Robert C. Martin’s books, “Clean Code” and “Clean Architecture,” as well as online tutorials, courses, and blog posts dedicated to this topic. 

Keep Learning. Keep Coding.  

Have any questions/suggestions?  

Write to us at business@m2pfintech.com. Our expert team will get in touch with you.  

Subscribe to our newsletter and get the latest fintech news, views, and insights, directly to your inbox.

Follow us on LinkedIn and Twitter for insightful fintech tales curated for curious minds like you.

0 Comments

Submit a Comment

Your email address will not be published.

You May Also Like…