5 Pillars of Excellence

SOLID Principles: A Swift Approach to Robust Software Development

Building Better iOS Apps: A Guide to SOLID Principles in Swift

Kanagasabapathy Rajkumar
10 min readJan 14, 2024
SOLID Principles in Swift

Applying design principles is the key to creating high-quality software

Symptoms of Obselete Design

  1. Rigidity — code suffering from rigidity becomes resistant to change
  2. Fragility — code tends to break in unexpected places
  3. Immobility —code lacks adaptability
  4. Viscosity — code resists the adoption of best practices

This article has been updated and moved to my website here

SOLID Principles

SOLID is an acronym for the first five object-oriented design (OOD) principles by Robert C. Martin (also known as Uncle Bob).

SOLID stands for:

  • S — Single Responsibility Principle
  • O — Open Closed Principle
  • L — Liskov’s Substitution Principle
  • I — Interface Segregation Principle
  • D — Dependency Inversion Principle

Please find the GitHub Repo of this example project — MVVM, SwiftUI

I have integrated Swift Package — NetworkKit in my project. Please take a look

Single Responsibility

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

Classes

A class should have only one reason to change, meaning it should encapsulate only one responsibility.

Methods

Methods within a class should align with the single responsibility of that class.

Modules

Modules (Swift frameworks, libraries) should be organized such that each module has a clear and single responsibility.

Bad Design

// Bad example
class UserDataManager {
func fetchData() {
// Fetch data from the server
}

func parseData() {
// Parse raw data into user objects
}

func displayData() {
// Update UI with user data
}
}
Photo by Patrick on Unsplash
// A monolithic service class handling all responsibilities
protocol ProductService {
func addProduct() async throws -> Product
func fetchProducts() async throws -> Products
func updateProducts(id: String, productTitle: String) async throws -> Product
func deleteProducts(id: String) async throws -> Product
}

// Implementation
final class MonolithicProductService: ProductService {
// ... implementation details
}

A bad approach would be to have a single monolithic service class handling all product-related functionalities without breaking them down into separate protocols. This violates SRP, making the code harder to understand, maintain, and extend.

Good Design

// Good example
class UserDataManager {
func fetchData(completion: @escaping ([User]) -> Void) {
// Fetch data from the server
// Call completion with parsed user data
}
}

class UserViewModel {
func displayData(users: [User]) {
// Update UI with user data
}
}
Photo by Orgalux on Unsplash
// Separate protocols for each responsibility
protocol ProductAdding {
func addProduct() async throws -> Product
}

protocol ProductFetching {
func fetchProducts() async throws -> Products
}

protocol ProductUpdating {
func updateProducts(id: String, productTitle: String) async throws -> Product
}

protocol ProductDeleting {
func deleteProducts(id: String) async throws -> Product
}

protocol ProductServiceRepository: ProductAdding, ProductFetching, ProductUpdating, ProductDeleting { }

// Implementation
final class ProductServiceRepositoryImpl: ProductServiceRepository {
// ... implementation details
}

In the current implementation, the ProductServiceRepository adheres to SRP by providing separate protocols for adding, fetching, updating, and deleting products. Each protocol represents a single responsibility, making the code modular and maintainable.

Open Closed

Software entities, including classes, modules and functions, should be open for extension but closed for modification.

Example — Instead of changing the existing functionalities, we should be able to add new features through extensions.

Classes:

Classes should be open for extension but closed for modification. This is often achieved through the use of protocols and extensions.

Methods:

Methods should be designed to be easily extensible without modifying existing code.

Modules:

Modules should allow for extension without requiring modification.

Bad Design

// Modifying existing protocols for new functionality
protocol ProductService {
func addProduct() async throws -> Product
func fetchProducts() async throws -> Products
func updateProducts(id: String, productTitle: String) async throws -> Product
func deleteProducts(id: String) async throws -> Product
// Modifying for reporting
func generateReport() async throws -> Report
}

// Implementation
final class ProductServiceImplementation: ProductService {
// ... implementation details
}

Modifying existing protocols or service classes each time a new functionality is added would violate the OCP. This can lead to a codebase that is fragile and prone to errors during modifications.

Good Design

// Adding a new protocol for a new functionality
protocol ProductReporting {
func generateReport() async throws -> Report
}

// Extending ProductServiceRepository to support reporting
final class ProductServiceRepositoryImpl: ProductServiceRepository, ProductReporting {
// ... implementation details
func generateReport() async throws -> Report {
// ... generate report logic
}
}

The ProductServiceRepository and its protocols are designed to be open for extension and closed for modification. You can easily add new functionalities by creating new protocols and conforming to them without modifying existing code.

Liskov Substitution

Derived types must be substitutable for their base types — Barbar Liskov

Classes:

Subtypes should be substitutable for their base types without altering program correctness.

Methods:

Overriding methods in subclasses should maintain the expected behaviour of the superclass.

Modules:

Substitutability should be maintained when extending or creating new modules.

Bad Design

// A single protocol for all product-related functionalities
protocol ProductOperator {
func addProduct() async throws -> Product
func updateProducts(id: String, productTitle: String) async throws -> Product
func deleteProducts(id: String) async throws -> Product
}

// ProductServiceRepositoryImpl conforms to the combined protocol
final class BadProductServiceRepositoryImpl: ProductOperator {
// Implementation details...
func addProduct() async throws -> Product {
// Logic to add a product...
return Product(id: 1, title: "New Product", description: "Description", rating: 4.5, stock: 100, brand: "Brand", category: "Category", thumbnail: "thumbnail.jpg", images: [])
}

func updateProducts(id: String, productTitle: String) async throws -> Product {
// Logic to update a product...
return Product(id: Int(id) ?? 0, title: productTitle, description: "Updated Description", rating: 4.7, stock: 150, brand: "Brand", category: "Category", thumbnail: "updated_thumbnail.jpg", images: ["image1.jpg", "image2.jpg"])
}

func deleteProducts(id: String) async throws -> Product {
// Logic to delete a product...
return Product(id: Int(id) ?? 0, title: "Deleted Product", description: "Description", rating: 3.8, stock: 0, brand: "Brand", category: "Category", thumbnail: "deleted_thumbnail.jpg", images: [])
}
}

Usage

// Using a single protocol for all operations
func processProduct(productOperator: ProductOperator) async {
do {
let newProduct = try await productOperator.addProduct()
// Process the new product...
print("Added product: \(newProduct.title)")
} catch {
print("Error adding product: \(error)")
}
}

// Creating an instance of BadProductServiceRepositoryImpl
let badProductService = BadProductServiceRepositoryImpl()

// Passing the instance to the function without any issue
await processProduct(productOperator: badProductService)

In this bad example, there’s a single protocol ProductOperator for all product-related functionalities. This violates the Liskov Substitution Principle, as the processProduct function may not work correctly with instances that only implement a subset of the methods.

This bad approach can lead to unexpected behaviour when substituting instances, as the combined protocol requires all methods to be implemented.

Good Design

// Protocols representing product-related functionalities
protocol ProductAdding {
func addProduct() async throws -> Product
}

protocol ProductUpdating {
func updateProducts(id: String, productTitle: String) async throws -> Product
}

protocol ProductDeleting {
func deleteProducts(id: String) async throws -> Product
}

// ProductServiceRepository conforms to these protocols
final class ProductServiceRepositoryImpl: ProductAdding, ProductUpdating, ProductDeleting {
// Implementation details...
func addProduct() async throws -> Product {
// Logic to add a product...
return Product(id: 1, title: "New Product", description: "Description", rating: 4.5, stock: 100, brand: "Brand", category: "Category", thumbnail: "thumbnail.jpg", images: [])
}

func updateProducts(id: String, productTitle: String) async throws -> Product {
// Logic to update a product...
return Product(id: Int(id) ?? 0, title: productTitle, description: "Updated Description", rating: 4.7, stock: 150, brand: "Brand", category: "Category", thumbnail: "updated_thumbnail.jpg", images: ["image1.jpg", "image2.jpg"])
}

func deleteProducts(id: String) async throws -> Product {
// Logic to delete a product...
return Product(id: Int(id) ?? 0, title: "Deleted Product", description: "Description", rating: 3.8, stock: 0, brand: "Brand", category: "Category", thumbnail: "deleted_thumbnail.jpg", images: [])
}
}

Usage

// Using protocols to maintain Liskov Substitution
func processProduct(productOperator: ProductAdding) async {
do {
let newProduct = try await productOperator.addProduct()
// Process the new product...
print("Added product: \(newProduct.title)")
} catch {
print("Error adding product: \(error)")
}
}

// Creating an instance of ProductServiceRepositoryImpl
let productService = ProductServiceRepositoryImpl()

// Passing the instance to the function without any issue
await processProduct(productOperator: productService)

The ProductServiceRepositoryImpl class conforms to the ProductAdding, ProductUpdating, and ProductDeleting protocols. The processProduct function accepts any object conforming to the ProductAdding protocol, allowing you to substitute instances without any issue.

The ProductServiceRepositoryImpl class adheres to the LSP by implementing the protocols without changing the behaviour of the base protocols. This allows instances of the implementing class to be substituted wherever the base protocols are used.

Interface Segregation

Many client specific interfaces are better than one general purpose interface

Classes:

A class should not be forced to implement interfaces it does not use. This is often achieved through the creation of smaller, more specific protocols.

Methods:

Methods within a protocol should be cohesive and cater to the specific needs of the conforming classes.

Modules:

Modules should expose interfaces tailored to the requirements of the client.

Bad Design

// Not following ISP
protocol Worker {
func work()
func takeBreak()
}

class OfficeWorker: Worker {
func work() { /* ... */ }
func takeBreak() { /* ... */ }
}
// A single interface with all methods
protocol ProductService {
func addProduct() async throws -> Product
func fetchProducts() async throws -> Products
func updateProducts(id: String, productTitle: String) async throws -> Product
func deleteProducts(id: String) async throws -> Product
}

// Implementation forced to conform to unnecessary methods
final class ProductServiceImplementation: ProductService {
// ... implementation details
}

If a single interface included all methods for adding, fetching, updating, and deleting products, classes implementing that interface might be forced to provide implementations for methods they don’t need. This violates ISP.

Photo by Nareeta Martin on Unsplash

Good Design

// Following ISP
protocol Workable {
func work()
}

protocol Breakable {
func takeBreak()
}

class OfficeWorker: Workable, Breakable {
func work() { /* ... */ }
func takeBreak() { /* ... */ }
}
// Specific protocols for each responsibility
protocol ProductAdding {
func addProduct() async throws -> Product
}

protocol ProductFetching {
func fetchProducts() async throws -> Products
}

protocol ProductUpdating {
func updateProducts(id: String, productTitle: String) async throws -> Product
}

protocol ProductDeleting {
func deleteProducts(id: String) async throws -> Product
}

// ProductServiceRepository conforms to specific protocols
final class ProductServiceRepositoryImpl: ProductServiceRepository {
// ... implementation details
}

The protocols ProductAdding, ProductFetching, ProductUpdating, and ProductDeleting adhere to ISP by having specific methods related to their respective responsibilities. This prevents classes from implementing unnecessary methods.

Dependency Inversion

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Abstractions should not depend on details. Details should depend on abstractions.

Classes:

High-level modules should not depend on low-level modules. Both should depend on abstractions (protocols).

Methods:

Methods should depend on abstractions, allowing for flexibility in the implementation.

Modules:

Modules should depend on abstractions, promoting a modular and loosely coupled architecture.

Bad Design

// Not following DIP
class LightBulb {
func turnOn() { /* ... */ }
func turnOff() { /* ... */ }
}

class Switch {
let bulb = LightBulb()

func operate() {
bulb.turnOn()
// Switch-specific logic
}
}
// Depending on concrete implementations directly
final class BadProductServiceRepositoryImpl: ProductServiceRepository {
// ... implementation details
}

// High-level module depends on concrete implementations
let productService: ProductServiceRepository = BadProductServiceRepositoryImpl()

Depending on concrete implementations directly within ProductServiceRepositoryImpl would violate DIP. It makes the code less flexible and resistant to changes, as any modifications to the implementations would affect the higher-level module.

Good Design

// Good example
protocol Switchable {
func turnOn()
}

class LightBulb: Switchable {
func turnOn() {
// Turn on logic
}
}

class Switch {
var device: Switchable

init(device: Switchable) {
self.device = device
}

func operate() {
device.turnOn()
}
}
// Depending on abstractions (protocols)
final class ProductServiceRepositoryImpl: ProductServiceRepository {
// ... implementation details
}

// High-level module depends on abstractions
let productService: ProductServiceRepository = ProductServiceRepositoryImpl()

The ProductServiceRepositoryImpl depends on abstractions (ProductAdding, ProductFetching, etc.) rather than concrete implementations. This feature enables the user to modify or extend the functionality of the high-level module without altering it.

Applying these principles in Swift allows you to create more maintainable, flexible, and scalable code. Remember that these principles often work together, and it’s crucial to strike a balance that fits the specific requirements of your application.

Grateful for your read, now let’s code on

Interested in connecting? 
Feel free to connect with me on: LinkedIn or follow on GitHub

Please take a look at my first-ever Article 👇

--

--

Kanagasabapathy Rajkumar

Swift Enthusiast | Building Seamless iOS Experiences 🚀 | Swift & Objective-C |