Home Articles

SOLID Principles: A Swift Approach to Robust Software Development

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

Demo of SOLID Principles

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

Architecture

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

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

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
        }
    }
    

Bad SRP


    // 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
        }
    }

Good SRP


    // 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 Principle

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.

OCP

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.

LSP

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.

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 Principle

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.

DIP

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.

The entire project can be found here

Alternate project can be found here

This is a free third party commenting service we are using for you, which needs you to sign in to post a comment, but the good bit is you can stay anonymous while commenting.