Swift-SOLID Principles

Cem Eke
8 min readDec 1, 2021

--

The principle of SOLID stands for five different conventions of codding. If you follow this principles you can improve the quality of your code.

SOLID represents 5 principles

  • Single-Responsibility Principle (SRP)
  • Open-Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency inversion Principle (DIP)
SOLID

Single Responsibility Principle

Robert C. Martin describes it as:

A class should have one, and only one, reason to change.

According to this principle each module(class, func, etc.) should have only one responsibility and reason to change. This means that every class or similar structure, in your code should have only one job to do.This principle helps you to keep your classes as clean as possible.

Let us check this with an example.

class Handler {   func handle() {    let data = requestDataToAPI()    let array = parse(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 DB }}

Handler class has responsibility for many things like retrieves the data from the API , parses the API response, creating an array of String and saves the array in a database.

So we can solve this problem moving the 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() {
let data = apiHandler.requestDataToAPI()
let array = 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 DB
}
}

This principle helps you to keep your classes as clean as possible.

Open-Closed Principle (OCP)

“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.” - Open-Closed Principle

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

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

As an example, we have a class Logger which iterates an array of Cats and prints the details of each cat:

class Logger {func printData() {
let cats = [
Cat(name: "Latte", color: "Yellow"),
Cat(name: "Po", color: "Black")
]
cats.forEach { cat in
print(cat.printDetails())
}
}
}
class Cat {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "It's \(name) and his color is \(color)"
}
}

If you want to add the possibility to print also the details of a new class, we should change the implementation of printData every time we want to log a new class.

class Logger {func printData() {
let cats = [
Cat(name: "Latte", color: "Yellow"),
Cat(name: "Po", color: "Black")

]
cats.forEach { cat in
print(cat.printDetails())
}
let dogs = [
Dog(name: "Molly", color: "Green"),
Dog(name: "Ted", color: "Brown")
]
dogs.forEach { dog in
print(dog.printDetails())
}
}
}
class Cat {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "It's \(name) and his color is \(color)"
}
}
class Dog {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "It's \(name) and his color is \(color)"
}
}

We can solve this problem by the take advantage of Protocols. We will create a new abstract layer between printData and the class Log.

protocol Printable {
func printDetails() -> String
}
class Logger {func printData() {
let cats: [Printable] = [
Cat(name: "Latte", color: "Yellow"),
Cat(name: "Po", color: "Black")
Dog(name: "Molly", color: "Green"),
Dog(name: "Ted", color: "Brown")
]
cats.forEach { cat in
print(cat.printDetails())
}
}
}
class Cat: Printable {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "It's \(name) and my color is \(color)"
}
}
class Dog: Printable {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "It's \(name) and my color is \(color)"
}
}

Liskov Substitution Principle (LSP)

“Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it.” -The Liskov Substitution Principle

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 Logger which is responsible to save data to DB. We assume that the business logic changes and you must save the string just if its length is greater than ten. Therefore, we decide to create a subclass FilteredLogger:

class Logger {func save(string: String) {
// Save string in the Cloud
}
}
class FilteredLogger: Logger {override func save(string: String) {
guard string.characters.count > 10 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 10. A client of Logger doesn’t expect that FilteredHandler has a different precondition, since it should be the same for Handler and all its subclasses.

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

class Logger {func save(string: String, minChars: Int = 0) {
guard string.characters.count >= minChars else { return }
// Save string in the Cloud
}
}

Postconditions Changes

We have a project where we must compute the area of some rectangle objects — so we create the class Rectangle. After a couple of months, we need to compute also the area of square objects—so we decide to create a subclass Square. Since in a square we need just a side to compute the area—and we don’t want to override the computation of area—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.

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: Floatinit(side: Float) {
self.side = side
}
var area: Float {
return pow(side, 2)
}
}
// Client Methodfunc printArea(of polygon: Polygon) {
print(polygon.area)
}
// Usagelet rectangle = Rectangle(width: 2, length: 5)
printArea(of: rectangle) // 10
let square = Square(side: 2)
printArea(of: square) // 4

Interface Segregation Principle (ISP)

Many client-specific interfaces are better than one general-purpose interface

It is saying that Clients should not be forced to depend upon interfaces that they do not use.

protocol Move {
func walk()
func fly()
}
class Bird: Move {
func walk()
func fly()
}
class People: Move {
func walk()
func fly()--> don't need to implement this
}

Following it’s the code supporting the Interface Segregation Principle by splitting the Move interface in 2 different interfaces(Walk, Fly) . Also, if we need another functionality we can create another interface “X”.

protocol Walk {
func walk()
}
protocol Fly {
func fly()
}
class Bird: Walk, Fly{
func walk()
func fly()
}
class People: Walk {
func walk()
}

We don’t need to implement func fly() in to the People class anymore. In this way, we use the ISP benefits like provide thin interfaces that can be reused in multiple places, provide groups of operations that logically belong together, less likely to break the Liskov Substitution Principle and flexibility.

Dependency Inversion Principle

“ High-level modules should not depend upon low-level modules. Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions.” -The Dependency Inversion Principle

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 the 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 kind of storages like a database, cloud, and so on.

We can solve this dependency using protocol Storage. In this way, Handlercan 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)
}
}

If you follow SOLID principles judiciously, you can increase the quality of your code. Moreover, your components can become more maintainable and reusable.

On the other hand we have also seen how useful the use of Protocol is and provides convenience.

--

--