Solidifying Your Swift Code: Understanding and Applying the SOLID Principles

Pankaj Gaikar
7 min readMar 17, 2023

--

SOLID principles are a set of five design principles for writing clean, maintainable, and scalable code. These principles were introduced by Robert C. Martin in his book, “Agile Software Development, Principles, Patterns, and Practices.” In this article, we will explore how these principles can be applied in Swift.

S — Single Responsibility Principle:

The Single Responsibility Principle (SRP) states that each class or module should have a single responsibility. It means that a class or module should do one thing and do it well. In Swift, you can apply SRP by creating classes and modules that are focused on a single task. For example, if you are building a news app, you can create a separate class for fetching news, parsing news, and displaying news. This way, each class will have a single responsibility, and it will be easier to test and maintain the code.

Example — Let’s say we have a class NewsFetcher that fetches news from a remote API and also handles parsing and displaying of the news. This class violates the SRP because it has multiple responsibilities. We can refactor it by separating each responsibility into its own class:

class NewsFetcher {
let apiClient: APIClient

init(apiClient: APIClient) {
self.apiClient = apiClient
}

func fetchNews(completion: @escaping ([News]) -> Void) {
apiClient.fetchData(endpoint: "news") { data in
let news = self.parseNews(from: data)
completion(news)
}
}

private func parseNews(from data: Data) -> [News] {
// Parse the news from the data
return news
}
}

class NewsParser {
func parse(from data: Data) -> [News] {
// Parse the news from the data
return news
}
}

class NewsDisplay {
func display(_ news: [News]) {
// Display the news in the UI
}
}

Now, the NewsFetcher class only fetches the news and calls the completion handler, while the NewsParser class is responsible for parsing the news data and returning an array of News objects, and the NewsDisplay class is responsible for displaying the news in the UI.

O — Open-Closed Principle:

The Open-Closed Principle (OCP) states that a class should be open for extension but closed for modification. It means that you should be able to extend the functionality of a class without changing its implementation. In Swift, you can apply OCP by using protocols and extensions. For example, you can create a protocol for a view controller and define its common behavior. Then, you can create a new class that conforms to this protocol and adds new functionality.

Example — Let’s say we have a Shape class with a draw method that draws the shape in the UI. Now, we want to add a new type of shape, a Triangle. Instead of modifying the Shape class to add the new shape, we can create a new subclass of Shape:

class Shape {
func draw() {
// Draw the shape in the UI
}
}

class Rectangle: Shape {
override func draw() {
// Draw a rectangle in the UI
}
}

class Circle: Shape {
override func draw() {
// Draw a circle in the UI
}
}

class Triangle: Shape {
override func draw() {
// Draw a triangle in the UI
}
}

Now, we can create an instance of Triangle and call its draw method without modifying the Shape class.

L — Liskov Substitution Principle:

The Liskov Substitution Principle (LSP) states that a subclass should be substitutable for its parent class. It means that you should be able to use a subclass object in place of a parent class object without affecting the correctness of the program. In Swift, you can apply LSP by making sure that the subclass does not violate the behavior of the parent class. For example, if you have a class that represents a rectangle, you can create a subclass that represents a square. However, the square should behave like a rectangle and not violate any of its properties or behaviors.

Example — Let’s say we have a Rectangle class with width and height properties, and a Square class that inherits from Rectangle and sets its width and height properties to the same value. The Square class violates the LSP because it behaves differently than its parent class:

class Rectangle {
var width: Double
var height: Double

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

class Square: Rectangle {
override init(width: Double, height: Double) {
super.init(width: width, height: height)
self.width = width
self.height = width
}
}

To fix this, we can create a separate Square class that doesn't inherit from Rectangle:

class Rectangle {
var width: Double
var height: Double

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

class Square {
var sideLength: Double

init(sideLength: Double) {
self.sideLength = sideLength

I — Interface Segregation Principle:

The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use. It means that you should create small and focused interfaces that only contain the methods that the client needs. In Swift, you can apply ISP by creating protocols that contain only the necessary methods. For example, if you have a class that implements a network request, you can create a protocol that contains only the methods for starting and canceling the request.

Example — Let’s say we have a protocol Animal with methods for eat, sleep, and swim. Now, we want to create a class Dog that conforms to the Animal protocol, but doesn't know how to swim:

protocol Animal {
func eat()
func sleep()
func swim()
}

class Dog: Animal {
func eat() {
// Dog eats
}

func sleep() {
// Dog sleeps
}

func swim() {
// Dog can't swim, but needs to implement this method
}
}

In this case, the Dog class violates the ISP because it is forced to implement a method that it doesn't need. To fix this, we can split the Animal protocol into smaller, more focused protocols:

protocol Animal {
func eat()
func sleep()
}

protocol Swimmable {
func swim()
}

class Dog: Animal {
func eat() {
// Dog eats
}

func sleep() {
// Dog sleeps
}
}

class Fish: Animal, Swimmable {
func eat() {
// Fish eats
}

func sleep() {
// Fish sleeps
}

func swim() {
// Fish swims
}
}

Now, the Dog class only needs to conform to the Animal protocol, while the Fish class conforms to both the Animal and Swimmable protocols. This way, each class only needs to implement the methods that are relevant to it, and we avoid forcing classes to implement methods that they don't need.

D — Dependency Inversion Principle:

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. It means that you should create abstractions for the low-level modules and use them in the high-level modules. In Swift, you can apply DIP by using protocols and dependency injection. For example, if you have a class that depends on a network request, you can create a protocol for the network request and inject it into the class.

Example — Let’s say we have a DataManager class that handles the saving and loading of data from a local database. This class depends on a specific database library, such as Realm. Now, if we decide to switch to a different database library, such as CoreData, we would need to modify the DataManager class. This violates the DIP, as the high-level DataManager class is dependent on a low-level implementation detail.

To fix this, we can use dependency injection to invert the dependencies. We can create a protocol Database that abstracts away the implementation details of the database library, and have the DataManager class depend on this protocol instead of the concrete implementation:

protocol Database {
func save(_ object: Any)
func load() -> [Any]
}

class RealmDatabase: Database {
// Implement the database methods using Realm
}

class CoreDataDatabase: Database {
// Implement the database methods using CoreData
}

class DataManager {
let database: Database

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

func saveData(_ data: Any) {
database.save(data)
}

func loadData() -> [Any] {
return database.load()
}
}

Now, the DataManager class is decoupled from the specific implementation of the database, and can work with any class that conforms to the Database protocol. We can create different implementations of the Database protocol for different database libraries, and inject the appropriate implementation into the DataManager class when we create an instance of it. This way, we can easily switch between different database libraries without modifying the DataManager class.

Conclusion:

In conclusion, applying SOLID principles in Swift can help you write clean, maintainable, and scalable code. The Single Responsibility Principle can help you create classes and modules that are focused on a single task. The Open-Closed Principle can help you extend the functionality of a class without changing its implementation. The Liskov Substitution Principle can help you create subclasses that behave like their parent class. The Interface Segregation Principle can help you create small and focused interfaces that only contain the necessary methods. The Dependency Inversion Principle can help you create abstractions for low-level modules and use them in high-level modules. By following these principles, you can write code that is easy to test, maintain, and scale.

--

--

Pankaj Gaikar
Pankaj Gaikar

Written by Pankaj Gaikar

Hey There, I’m an iOS Developer. I love to create iOS apps that would make life easy and enjoyable for people. This blog is dedicated to stuff about something.

No responses yet