SOLID Principles in Swift

Knyaz Harutyunyan
8 min readSep 9

--

Hello everyone! I’m excited to share my knowledge about SOLID Principles and how it organized in Swift. For a deeper dive into Swift and UIKit, be sure to check out my primary article at this link: link 🚀

SOLID

I thought every developer who wants to have flexible, dynamic, and effective applications should follow SOLID principles.

SOLID represents 5 principles of object-oriented programming:

  1. Single-responsibility principle
  2. Open–closed principle
  3. Liskov substitution principle
  4. Interface segregation principle
  5. Dependency inversion principle

First, let’s briefly explore all of the SOLID principles, and then dive into them with practical examples.

  1. The Single-responsibility principle: “There should never be more than one reason for a class to change.”In other words, every class should have only one responsibility”.
  2. The Open–closed principle: “Software entities … should be open for extension, but closed for modification.”
  3. The Liskov substitution principle: “Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.”
  4. The Interface segregation principle: “Clients should not be forced to depend upon interfaces that they do not use.”
  5. The Dependency inversion principle: “Depend upon abstractions, [not] concretions.”

# Single-responsibility principle
A common mistake in development is creating classes that handle multiple responsibilities. Each class should have a single, well-defined purpose. For instance, avoid creating a class responsible for network calls, handling functions, passing data to ViewControllers, and more.

# Bad Example

class NetworkManager {
static let shared = NetworkManager()

private init() {}

func handleAllActions() {
let userData = getUsers()
let userArray = parseDataToJson(data: userData)
saveDataToDB(users: userArray)
}

func getUsers() -> Data {
// send API request and wait for response
}

func parseDataToJson(data: Data) -> [String] {
// parse the data and convert to array
}

func saveDataToDB(users: [String]) {
// save that array into CoreData...
}
}

# Good Example

class NetworkManager {
var userAPIHandler: UserAPIHandler?
var parseDataHandler: ParseDataHandler?
var saveDataToDBHandler: SaveDataToDBHandler?

init(userAPIHandler: UserAPIHandler, parseDataHandler: ParseDataHandler, saveDataToDBHandler: SaveDataToDBHandler) {
self.userAPIHandler = userAPIHandler
self.parseDataHandler = parseDataHandler
self.saveDataToDBHandler = saveDataToDBHandler
}

func handleAllActions() {
guard let userAPIHandler else { return }
guard let parseDataHandler else { return }
guard let saveDataToDBHandler else { return }

let userData = userAPIHandler.getUsers()
let userArray = parseDataHandler.parseDataToJson(data: userData)
saveDataToDBHandler.saveDataToDB(users: userArray)
}
}

class UserAPIHandler {
func getUsers() -> Data {
//Send API request and wait for a response
}
}

class ParseDataHandler {
func parseDataToJson(data: Data) -> [String] {
// parse the data and convert it to array
}
}

class SaveDataToDBHandler {
func saveDataToDB(users: [String]) {
// save that array into CoreData...
}
}

Notice how each class has a single, clear responsibility and doesn’t perform multiple tasks.

# Open–closed principle
Every class, structure, and so on should open for extension but close for modification. Swift has powerful features that align with this principle, one of which is the Extension feature. For instance, if we want to add functionality to the native String structure that Swift provides but don’t have access to modify the String’s code directly, Swift offers us Extensions to add that functionality. When working with custom types, we have the ability to modify the code. However, it’s essential to always keep the Open/Closed principle in mind, avoid common mistakes, and take care when handling our custom types. Also, the Protocols keep the Open/Close principle.

# Bad Example

class Cat {
var name: String

init(name: String) {
self.name = name
}

func animalInfo() -> String {
return "I am Cat and name is \(self.name)"
}
}

class Fish {
var name: String

init(name: String) {
self.name = name
}

func animalInfo() -> String {
return "I am fish and name is \(self.name)"
}
}

class AnimalsInfo {
func printData() {
let cats = [Cat(name: "Luna"), Cat(name: "Tina"), Cat(name: "Moon")]

for cat in cats {
print(cat.animalInfo())
}

let fishes = [Fish(name: "Ishxan"), Fish(name: "Karas"), Fish(name: "Sterlec"), Fish(name: "fish")]
for fish in fishes {
print(fish.animalInfo())
}
}
}

let infoOfAnimals = AnimalsInfo()
infoOfAnimals.printData()

This is a flawed example because adding a new type of animal in the future would require changing the ‘AnimalsInfo’ class’s ‘printData’ function, violating the Open/Closed Principle.

# Good Example

protocol Info {
func animalInfo() -> String
}

class Cat: Info {
var name: String

init(name: String) {
self.name = name
}

func animalInfo() -> String {
return "I am Cat and name is \(self.name)"
}
}

class Fish: Info {
var name: String

init(name: String) {
self.name = name
}

func animalInfo() -> String {
return "I am fish and name is \(self.name)"
}
}

class AnimalsInfo {
func printData() {
let animalsInfo: [Info] = [
Cat(name: "Luna"),
Cat(name: "Tina"),
Cat(name: "Moon"),
Fish(name: "Ishxan"),
Fish(name: "Karas"),
Fish(name: "Sterlec"),
Fish(name: "fish")
]

for info in animalsInfo {
print(info.animalInfo())
}
}
}

let infoOfAnimals = AnimalsInfo()
infoOfAnimals.printData()

As you can see, this approach is highly flexible and effective because I won’t encounter any issues when new animals are added to my file. This is because all classes should conform to the ‘Info’ protocol, which allows us to add extensions without modifying class behavior.

# Another Example with Extension

extension Int {
func multipleInt(of number: Int) -> Int {
return self * number
}

func isNegative() -> Bool {
if self < 0 {
return true
}
return false
}
}

let intValue = 99

print(intValue.multipleInt(of: 10)) // 990
print(intValue.isNegative()) // false

# Liskov substitution principle
Liskov Substitution Principle (LTSY) states that when we inherit from a base class, the subclass should not modify the behavior of the base class functions. Users can utilize both the base class functionality and the child class, which inherits the base class behavior.

# Bad Example

class Operators {
func add(num1: Int, num2: Int) -> Int{
return num1 + num2
}

func sub(num1: Int, num2: Int) -> Int{
return num1 - num2
}
}

class Calculator: Operators {
override func add(num1: Int, num2: Int) -> Int {
return num1 * num2
}

override func sub(num1: Int, num2: Int) -> Int {
return num1 + num2
}
}

let add = Operators()
print(add.add(num1: 5, num2: 5)) // cool works -> 10

let calc = Calculator()
print(calc.add(num1: 5, num2: 5)) // not working... why? The user is angry. -> 25

As you can see, I inherited from ‘Operators’ and modified the behavior of the ‘Operators’ class functions, which is not recommended.

# Good Example

class Operators {
func add(num1: Int, num2: Int) -> Int{
return num1 + num2
}

func sub(num1: Int, num2: Int) -> Int{
return num1 - num2
}
}

class Calculator: Operators {
override func add(num1: Int, num2: Int) -> Int {
return num1 + num2
}

override func sub(num1: Int, num2: Int) -> Int {
return num1 - num2
}

func add(num1: Int, num2: Int, num3: Int) -> Int{
return num1 + num2 + num3
}
}

let add = Operators()
print(add.add(num1: 5, num2: 5)) // cool works -> 10

let calc = Calculator()
print(calc.add(num1: 5, num2: 5)) // cool works -> 10

// also added a new function

print(calc.add(num1: 2, num2: 5, num3: 6))

This is correct because I haven’t altered the behavior of the ‘Base (Parent)’ class, allowing users to use both the base and child classes in the same manner.

# Interface segregation principle
The Interface Segregation Principle states that users should not depend on interfaces or functionality they don’t need. It advises against providing overly complex or hard-to-understand interfaces. The interface should be easy and should not contain complex elements that the user cannot understand or use.

# Bad Example

protocol Animals {
func eat()
func fly()
func swim()
}

class Flamingo: Animals {
func eat() {
print("I can eat")
}

func fly() {
print("I can fly")
}

func swim() {
print("I can swim")
}
}

class Dogs: Animals {
func eat() {
print("I can eat")
}

func fly() {
print("I cannot fly")
fatalError()
}

func swim() {
print("I cannot swim")
fatalError()
}
}

As you can see, the ‘Flamingo’ and ‘Dogs’ classes conform to the ‘Animals’ protocol. However, the ‘Dogs’ class, which cannot fly and swim, has been overloaded with unnecessary functionality. This approach is not recommended. I’ve provided a simple example, but you can apply the same evaluation to your UIKit classes to determine if you are overloading them.

# Good Example

protocol Flyable {
func fly()
}

protocol Swimmable {
func swim()
}

protocol Feedable {
func eat()
}

class Flamingo: Flyable, Swimmable, Feedable {
func eat() {
print("I can eat")
}

func fly() {
print("I can fly")
}

func swim() {
print("I can swim")
}
}

class Dogs: Feedable {
func eat() {
print("I can eat")
}
}

I’ve created three separate protocols to eliminate unnecessary overloading.
The Flamingo can fly, swim, and eat for that reason the “Flamingo” class conforms to three protocols those are “Flyable”, “Swimmable”, and “Feedable” but the “Dogs” cannot fly and swim for that reason the “Dogs” class to conform only “Feedable” protocol.

# Dependency inversion principle
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules. Instead, they should both depend on abstractions. In other words, you should depend on interfaces, not implementations.

# Bad Example

// Low-level class: Dog
class Dog {
func bark() {
print("Woof!")
}
}

// Low-level class: Cat
class Cat {
func meow() {
print("Meow!")
}
}

// High-level class: AnimalSoundMaker
class AnimalSoundMaker {
let dog: Dog
let cat: Cat

init(dog: Dog, cat: Cat) {
self.dog = dog
self.cat = cat
}

func makeDogSound() {
dog.bark()
}

func makeCatSound() {
cat.meow()
}
}

As you see the “AnimalSoundMaker” is a high-level class that directly depends on the “Dog” and “Cat” classes, which are low-level classes representing specific animals. “AnimalSoundMaker” has separate methods for making dog and cat sounds (makeDogSound() and makeCatSound()), and it directly invokes methods on “Dog” and “Cat” instances. As a result, any change or addition to animal types (adding a new animal like a bird) would require modifications to the “AnimalSoundMaker” class, making it less flexible and harder to maintain.

# Good Example

// Abstraction: Animal
protocol Animal {
func makeSound()
}

// Low-level class: Dog
class Dog: Animal {
func makeSound() {
print("Woof!")
}
}

// Low-level class: Cat
class Cat: Animal {
func makeSound() {
print("Meow!")
}
}

// High-level class: AnimalSoundMaker
class AnimalSoundMaker {
let animal: Animal

init(animal: Animal) {
self.animal = animal
}

func performSound() {
animal.makeSound()
}
}

As you see the “Animal” is an abstraction (protocol) that defines a method “makeSound()”. Both “Dog” and “Cat” classes implement this protocol.

AnimalSoundMaker” is a high-level class that depends on “Animal” abstraction. It accepts an “Animal” instance in its constructor, which can be any class conforming to the “Animal” protocol.

Dog” and “Cat” are low-level classes that implement the “Animal” protocol with their own “makeSound()” implementations. This allows you to add new animal types without modifying “AnimalSoundMaker”, promoting flexibility and maintainability.

Thank you for your attention while reading this article and I will be very happy if you share your opinions about this article.

I hope to see you soon with your suggestions and problems please write to me on LinkedIn.

iOS Interview Questions — https://medium.com/@knoo/ios-interview-questions-2023-7fd56079f363

ViewController Life Cycle in iOS — https://medium.com/@knoo/viewcontroller-life-cycle-in-ios-29f7da4acfc7

Application Life Cycle in iOS — https://medium.com/@knoo/application-life-cycle-in-ios-dd9e1f5c9053

GCD — Grand Central Dispatch in iOS — https://medium.com/@knoo/gcd-grand-central-dispatch-in-ios-b2dd665cabd5

ARC — https://medium.com/@knoo/arc-automatic-reference-counting-in-swift-e7ac473229cc

MVC, MVVM, and MVP design patterns — https://medium.com/@knoo/mvc-mvvm-and-mvp-design-patterns-in-swift-efeda4ec3c2b

Linkedin — https://www.linkedin.com/in/knyaz-harutyunyan-1a3672235/

GitHub — https://github.com/Kno777

Best Regards,

Knyaz Harutyunyan!

--

--