5 Pillars of Excellence
SOLID Principles: A Swift Approach to Robust Software Development
Building Better iOS Apps: A Guide to SOLID Principles in Swift
Applying design principles is the key to creating high-quality software
Symptoms of Obselete Design
- Rigidity — code suffering from rigidity becomes resistant to change
- Fragility — code tends to break in unexpected places
- Immobility —code lacks adaptability
- 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
}
}
// 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
}
}
// 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.
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.
Better understanding of JSON Parsing and Network Package Integration. Take a look at my articles.