iOS Swift S.O.L.I.D. Principles

Munendra Pratap Singh
8 min readAug 18, 2023

--

Background:

The SOLID principles were introduced by Robert C. Martin(a.k.a Uncle Bob) in his 2000 paper “Design Principles and Design Patterns.” These concepts were later built upon by Michael Feathers, who introduced us to the SOLID acronym. And in the last 20 years, these five principles have revolutionized the world of object-oriented programming, changing the way that we write software.

Uncle Bob is also the author of bestselling books Clean Code and Clean Architecture, and is one of the participants of the “Agile Alliance”.Therefore, it is not a surprise that all these concepts of clean coding, object-oriented architecture, and design patterns are somehow connected and complementary to each other.

They all serve the same purpose:

“To create understandable, readable, and testable code that many developers can collaboratively work on.”

The following five concepts make up our SOLID principles:

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

In this article, you will try to understand each principle individually with examples.

1. Single Responsibility Principle

Single-responsibility Principle (SRP) states:

“A class should have one and only one reason to change, meaning that a class should have only one job.”

As we might expect, this principle states that a class should only have one responsibility. Furthermore, it should only have one reason to change.

Let’s have an example:

class Invoice {
let book: Book
let quantity: Int
let discountRate: Int
let taxRate: Float
var total: Float = 0.0

init(book: Book, quantity: Int, discountRate: Int, taxRate: Float) {
self.book = book
self.quantity = quantity
self.discountRate = discountRate
self.taxRate = taxRate
self.total = self.calculateTotal()
}

public func calculateTotal() -> Float {
let price = ((book.price - book.price * discountRate) * quantity)
let priceWithTaxes = Float(price) * (1.0 + taxRate)
return priceWithTaxes
}

public func printInvoice() {
print("\(quantity) x \(book.name) \(book.price)$")
print("Discount Rate: \(discountRate)")
print("Tax Rate: \(taxRate)")
print("Total: \(total)")
}

public func saveToFile(filename: String) {
// Creates a file with given name and writes the invoice
}

}

Here is our invoice class. It also contains some fields about invoicing and 3 methods:

  • calculateTotal method, which calculates the total price,
  • printInvoice method, which should print the invoice to the console, and
  • saveToFile method, responsible for writing the invoice to a file.

You should give yourself a second to think about what is wrong with this class design before reading the next paragraph.

Ok, what's going on here? out Invoice class is violating the Single Responsibility Principle.

So our Invoice class is responsible to calculate the invoice, printing the invoice, and saving the invoice to file. But the Single Responsibility Principle is saying “A class should only have one responsibility”.

You may ask, how we can fix this class.

We can create new classes for our printing and persistence logic so we will no longer need to modify the invoice class for those purposes.

We create 2 classes, InvoicePrinter and InvoicePersistence, and move the methods.

class InvoicePrinter {

let invoice: Invoice

init(invoice: Invoice) {
self.invoice = invoice
}

public func printInvoice() {
print("\(invoice.quantity) x \(invoice.book.name) \(invoice.book.price) $");
print("Discount Rate: \(invoice.discountRate)")
print("Tax Rate: \(invoice.taxRate)")
print("Total: \(invoice.total)$");
}
}
class InvoicePersistence {

let invoice: Invoice

public init(invoice: Invoice) {
self.invoice = invoice
}

public func saveToFile(filename: String) {
// Creates a file with given name and writes the invoice
}
}

Now our class obeys the Single Responsibility Principle and every class is responsible for one aspect of our application

2. Open/Closed Principle

“Objects or entities should be open for extension but closed for modification.”

The Open-Closed Principle requires that classes should be open for extension and closed for modification.

Modification means changing the code of an existing class, and extension means adding new functionality.

So this principle says, we should be able to add new functionality without touching the existing code. This is because whenever we will modify the existing code, there will be a chance to introduce a new bug. We should avoid touching the production code.

Let's take an example. In our last example InvoicePersistence, we are saving the invoice in the file. Now our manager/client wants to save that incode in the Database as well.

What we will do? we will add a new function in InvoicePersistence class which will save the invoice in Database.

class InvoicePersistence {
let invoice: Invoice

public init(invoice: Invoice) {
self.invoice = invoice
}

public func saveToFile(filename: String) {
// Creates a file with given name and writes the invoice
}

public func saveToDataBase(filename: String) {
// save invoide in Database
}

}

Correct, As simple as that?

But it's a clear violation of the open/close principle. To add this fungibility we are modifying our existing class, which is tested and passed by QA, and the code is released in the market. That can create new bugs and QA needs to test the all code again.

How we can prevent that? let's take a look at the code example.

protocol InvoicePersistence {
func save(filename: String)
}

class FilePersistence: InvoicePersistence {
let invoice: Invoice

public init(invoice: Invoice) {
self.invoice = invoice
}

public func save(filename: String) {
// Creates a file with given name and writes the invoice
}
}

Here we have created a protocol InvoicePersistence with the save method. and created a class FilePersistence which saves our invoice in the file.

Now when our manager/client wants to save that incode in the Database as well.

Here is what we can do:-

class DatabasePersistence: InvoicePersistence {
let invoice: Invoice

public init(invoice: Invoice) {
self.invoice = invoice
}

public func save(filename: String) {
// save invoice in Database
}
}

we have created a new class DatabasePersistence which inherits the InvoicePersistence protocol and in this class, we can save invoices in the Database without touching the existing code.

3. Liskov Substitution

“Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.”

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

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

Let’s take an example:

class Rectangle {
let width: Int
let height: Int

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

public func getArea() -> Int {
return width * height
}
}

Now let’s create another class that inherits the Rectangle class

class Square: Rectangle {

init(size: Int) {
super.init(width: size, height: size)
}
}
func getTestArea(shape: Rectangle) {
print(shape.getArea())
}
//1:
let rect = Rectangle(width: 10, height: 20)
getTestArea(shape: rect)
//2:
let square = Square(size: 15)
getTestArea(shape: square)

we have created a function that accepts the Rectangle class object.

  1. We are calling getTestArea with the Rectangle object.
  2. getTestArea function is getting called with a Square object.

Both lines of code will compile successfully because the Square class is the subclass of the Rectangle class and the function/object which is accepting Rectangle can also accept Square class object.

4. Interface Segregation

“A client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use.”

Segregation means keeping things separated, and the Interface Segregation Principle is about separating the interfaces.

In summary, Clients should not be forced to depend upon interfaces that they do not use. No code should be forced to depend on methods it does not use.

Let’s jump right into the code and see the problem practically.

Let’s have an abstract structure called Worker, and in general, we expect those who inherit from the Worker class to be able to do the eat and work tasks.

First, let’s have a class called Human and this class inherits from the abstract structure Worker. Theoretically, we expect a person to both eat and work. Then we needed a robot structure and we inherited it from the Worker structure because the robot can work.

The problem starts here because the Worker protocol has two functions, one is work and one is eating, there is no problem with the work function because the robot can run, but since we inherit from the worker structure, we have to add the eat function as well, which causes an unnecessary responsibility to be passed to the class. This is an ISP break.

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")
}
}

In order to solve this problem, we must divide our responsibilities, which have an abstract structure, 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 the class will inherit from Feeble and Workable, and the Robot class from Workable only.

Thus, we do not impose any unnecessary responsibility on any class and we create a structure suitable for the ISP.

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")
}
}

5. Dependency Inversion

The Dependency Inversion principle states that our classes should depend upon interfaces or abstract classes instead of concrete classes and functions.

High-level modules should not depend on low-level modules both should depend on Abstractions. (Abstractions should not depend upon details. Details should depend upon abstractions)

class FileSystemManager {
func save(string: String) {
// Open a file
// Save the string in this file
// Close the file
}
}
class Handler {
let fileManager = FilesystemManager()
func handle(string: String) {
fileManager.save(string: string)
}
}

FileSystemManager is a low-level module and it’s easy to reuse in other projects. The problem is the high-level module Handler which is not reusable because is tightly coupled with FileSystemManager. We should be able to reuse the high-level module with different kinds of storage like a database, cloud, and so on.

We can solve this dependency using protocol Storage. In this way, Handler can use this abstract protocol without caring for the kind of storage used. With this approach, we can change easily from a filesystem to a database.

protocol Storage {
func save(string: String)
}
class FileSystemManager: Storage {
func save(string: String) {
// Open a file in read-mode
// Save the string in this file
// Close the file
}
}
class DatabaseManager: Storage {
func save(string: String) {
// Connect to the database
// Execute the query to save the string in a table
// Close the connection
}
}
class Handler {
let storage: Storage
// Storage types
init(storage: Storage) {
self.storage = storage
}

func handle(string: String) {
storage.save(string: string)
}
}

The dependency Inversion Principle is very similar to the Open-Closed Principle the approach to use, to have a clean architecture, is decoupling the dependencies. You can achieve it to abstract layers

--

--