Understanding SOLID Principles in Swift: Real-World Examples

Unlocking Code Maintainability: A Deep Dive into SOLID Principles with Swift Examples

Vikram Kumar
5 min readMar 25, 2024

SOLID is a set of design principles for writing maintainable and scalable object-oriented code. These principles help developers create code that is easier to understand, extend, and maintain.

In this article, we’ll explore each of the SOLID principles -

  1. Single Responsibility Principle
  2. Open/Closed Principle
  3. Liskov Substitution Principle
  4. Interface Segregation Principle
  5. Dependency Inversion Principle
Photo by Joshua Sortino on Unsplash

1. Single Responsibility Principle (SRP)

SRP states that a class should have only one reason to change, meaning it should have only one responsibility. If a class has multiple responsibilities, it becomes harder to understand, maintain, and test.

Real-world Example:

Consider a User class in an iOS app that is responsible for both user authentication and user profile management. Applying SRP, we can split this class into two separate classes: AuthenticationManager and UserProfileManager. This separation allows each class to have a single responsibility - managing authentication and managing user profiles, respectively.

// Before SRP
class User {
func login() { /* logic for login */ }
func updateProfile() { /* logic for updating profile */ }
}

// After SRP
class AuthenticationManager {
func login() { /* logic for login */ }
}

class UserProfileManager {
func updateProfile() { /* logic for updating profile */ }
}

By separating authentication and user profile management into distinct classes, we adhere to SRP, making our code easier to maintain and understand.

2. Open/Closed Principle (OCP)

OCP states that a class should be open for extension but closed for modification. This means that you should be able to extend the behavior of a class without modifying its source code.

Real-world Example:

Before OCP:
A PaymentProcessor class may have a switch statement to handle different payment methods. However, adding a new payment method requires modifying the existing class, violating the OCP.

After OCP:
Use protocols and create separate classes for each payment method:

protocol PaymentProcessor {
func processPayment()
}

class CreditCardProcessor: PaymentProcessor {
func processPayment() { /* logic for credit card payment */ }
}

class PayPalProcessor: PaymentProcessor {
func processPayment() { /* logic for PayPal payment */ }
}

Now, adding a new payment method involves creating a new class that conforms to the PaymentProcessor protocol, without modifying existing code.

3. Liskov Substitution Principle (LSP)

LSP states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In other words, subclasses should be able to extend the behavior of the superclass without breaking the functionality that the superclass provides.

Real-world Example:

Before LSP:
A Shape superclass may have a method to calculate area, with subclasses such as Rectangle and Circle overriding this method. However, the behavior of the superclass may not be consistent with its subclasses.

After LSP:
Define a protocol for shapes:

protocol Shape {
func calculateArea() -> Double
}

class Rectangle: Shape {
func calculateArea() -> Double { /* logic for rectangle area calculation */ }
}

class Circle: Shape {
func calculateArea() -> Double { /* logic for circle area calculation */ }
}

Now, subclasses conform to a common protocol, ensuring that they can be substituted for the superclass without altering program behavior.

4. Interface Segregation Principle (ISP)

ISP states that clients should not be forced to depend on interfaces they don’t use. Instead of implementing large, monolithic interfaces, classes should implement smaller, more specific interfaces based on the behaviors they need.

Real-world Example:

Before ISP: A UserPermissions protocol may have methods for both granting and revoking access. However, not all classes implementing this protocol may need both methods.

After ISP: Define smaller, more specific protocols:

protocol ReadPermission {
func grantReadAccess()
}

protocol WritePermission {
func grantWriteAccess()
}

class User: ReadPermission, WritePermission {
func grantReadAccess() { /* logic for granting read access */ }
func grantWriteAccess() { /* logic for granting write access */ }
}

Now, classes only depend on the interfaces they use, preventing unnecessary dependencies.

5. Dependency Inversion Principle (DIP)

DIP states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.

Real-world Example:

Let’s consider a real-world example where we have a DataManager class responsible for fetching data from a backend server. Initially, DataManager directly depends on a specific networking library, such as Alamofire, for making HTTP requests. However, we want to adhere to the Dependency Inversion Principle (DIP) to decouple DataManager from the concrete networking implementation.

Here’s how we can refactor the code to adhere to DIP:

import Foundation

// Protocol defining the contract for a network service
protocol NetworkService {
func fetchData(from url: URL, completion: @escaping (Result<Data, Error>) -> Void)
}

// Implementation of the network service using URLSession
class URLSessionNetworkService: NetworkService {
func fetchData(from url: URL, completion: @escaping (Result<Data, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
} else if let data = data {
completion(.success(data))
} else {
let unknownError = NSError(domain: "UnknownError", code: 0, userInfo: nil)
completion(.failure(unknownError))
}
}.resume()
}
}

// DataManager class that depends on the NetworkService protocol
class DataManager {
let networkService: NetworkService

init(networkService: NetworkService) {
self.networkService = networkService
}

func fetchData() {
guard let url = URL(string: "https://api.example.com/data") else {
print("Invalid URL")
return
}

networkService.fetchData(from: url) { result in
switch result {
case .success(let data):
// Process fetched data
print("Data fetched successfully: \(data)")
case .failure(let error):
// Handle error
print("Error fetching data: \(error)")
}
}
}
}

// Usage
let urlSessionNetworkService = URLSessionNetworkService()
let dataManager = DataManager(networkService: urlSessionNetworkService)
dataManager.fetchData()

In this example:

  • We define a protocol NetworkService that declares a method fetchData(from:completion:) for fetching data from a URL.
  • We implement the NetworkService protocol with URLSessionNetworkService, which uses URLSession to perform the network request.
  • The DataManager class now depends on the NetworkService protocol instead of a concrete networking implementation.
  • We can easily switch between different networking implementations (e.g., Alamofire, URLSession, etc.) by providing a different implementation of the NetworkService protocol to the DataManager constructor.

This approach adheres to the Dependency Inversion Principle, as DataManager depends on abstractions (protocols) rather than concrete implementations, making the code more flexible, reusable, and easier to maintain.

Conclusion

SOLID principles provide invaluable guidance for designing robust and maintainable Swift apps. By applying these principles to real-world scenarios, we can create codebases that are easier to understand, extend, and maintain, ultimately leading to higher-quality software solutions. As you continue your journey in Swift app development, keep these principles in mind and strive to write SOLID code!

Happy Coding!!!

--

--

Vikram Kumar

I am Vikram, a Senior iOS Developer at Matellio Inc. focused on writing clean and efficient code. Complex problem-solver with an analytical and driven mindset.