avatarCong Le

Summary

The provided context outlines the implementation of SOLID design principles in iOS development to create clean, maintainable, and scalable code.

Abstract

The SOLID principles are a set of five design principles aimed at improving the quality of software. In the context of iOS development, these principles guide developers in structuring their code to enhance maintainability, flexibility, reusability, scalability, and testability. The Single Responsibility Principle (SRP) ensures that each class has only one reason to change, promoting clarity and ease of maintenance. The Open/Closed Principle (OCP) allows for extension without modifying existing code, enabling flexibility. The Liskov Substitution Principle (LSP) ensures that subclasses can replace their parent classes without altering the program's behavior, which is crucial for robustness. The Interface Segregation Principle (ISP) advocates for small, specific interfaces over large, general-purpose ones, reducing unnecessary dependencies. Lastly, the Dependency Inversion Principle (DIP) suggests that high-level modules should not depend on low-level modules but on abstractions, facilitating modularity and easier testing. Practical examples are provided to illustrate how adhering to these principles can lead to better software design in iOS applications.

Opinions

  • The author emphasizes the importance of the SOLID principles for writing clean and maintainable code in iOS development.
  • Refactoring classes to adhere to SRP can significantly improve code modularity and maintainability.
  • Following OCP is crucial for creating systems that are easy to extend without introducing errors in existing functionality.
  • LSP is considered essential for ensuring that subclasses function correctly when used in place of their superclasses.
  • ISP is recommended to avoid forcing clients to depend on methods they do not use, leading to cleaner and more efficient code.
  • DIP is highlighted as a key principle for enabling loose coupling and easier testing by depending on abstractions rather than concrete implementations.
  • The use of protocols in Swift is encouraged to adhere to SOLID principles, particularly in the context of iOS app development.
  • The article suggests that applying SOLID principles can help manage complexity in growing applications, making them more scalable.
  • The author advocates for the use of SOLID principles to enhance the overall quality of software and facilitate smoother development processes.

Understand and implement SOLID Principles in iOS development

Mastering Clean Code Architecture with the Five SOLID Design Principles for Swift

Single Responsibility Principle (SRP):

  • Each class or module should have only one reason to change, meaning it should only have one responsibility or job.
  • In iOS, this could mean creating separate classes for handling network requests, data parsing, and UI updates.

Open/Closed Principle (OCP):

  • Software entities should be open for extension but closed for modification.
  • In iOS, this could involve using protocols and subclassing to extend the behavior of a class without changing its existing code.

Liskov Substitution Principle (LSP):

  • Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
  • In iOS, this means designing subclasses that can be used interchangeably with their parent class without causing unexpected behavior.

Interface Segregation Principle (ISP):

  • Interfaces should be small and specific.
  • Clients should not be forced to depend on interfaces they do not use.
  • In iOS, this could involve creating small, focused protocols that define specific behaviors, rather than large, all-encompassing ones.

Dependency Inversion Principle (DIP):

  • High-level modules should not depend on low-level modules; both should depend on abstractions.
  • In iOS, this could mean using protocols to define an abstraction layer between different parts of your app, allowing for easier testing and more flexible code.

Why are the SOLID principles important?

The SOLID principles are important because they provide a framework for writing clean, maintainable, and scalable code. Adhering to these principles can lead to several benefits in software development:

  1. Maintainability: When each class has a single responsibility, it’s easier to understand, modify, and debug. This reduces the risk of introducing new bugs when changes are made.
  2. Flexibility: By following the Open/Closed Principle, you can add new functionality without altering existing code, which means less chance of breaking existing functionality.
  3. Reusability: When classes are designed with clear, singular purposes and are decoupled from each other, they can be reused in different contexts, reducing code duplication.
  4. Scalability: As your application grows, SOLID principles help manage complexity by organizing code into manageable chunks that follow clear contracts.
  5. Testability: With the Dependency Inversion Principle, it’s easier to swap out concrete implementations with mocks or stubs, which facilitates unit testing and improves code quality.

Overall, SOLID principles help create a codebase that is easier to extend, understand, and maintain, which is particularly important as projects grow and evolve over time.

Practical examples

1. The Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) states that a class should have only one reason to change, meaning it should only have one job or responsibility. Here’s a simple example of how you might refactor a class to adhere to SRP in an iOS app:

Before applying SRP:

class UserProfileViewController: UIViewController {
    var user: User?
    
    func displayUserProfile() {
        // Code to fetch and display user profile
    }
    
    func updateUserProfile() {
        // Code to update user profile
    }
    
    func logUserActivity() {
        // Code to log user activity
    }
}

In this example, UserProfileViewController has multiple responsibilities: displaying the user profile, updating the user profile, and logging user activity. This violates SRP.

After applying SRP:

class UserProfileViewController: UIViewController {
    var user: User?
    private let userProfileDisplayService = UserProfileDisplayService()
    private let userProfileUpdateService = UserProfileUpdateService()
    private let userActivityLoggingService = UserActivityLoggingService()
    
    func displayUserProfile() {
        userProfileDisplayService.displayUserProfile(for: user)
    }
    
    func updateUserProfile() {
        userProfileUpdateService.updateUserProfile(for: user)
    }
    
    // Other methods related to the view...
}

class UserProfileDisplayService {
    func displayUserProfile(for user: User?) {
        // Code to fetch and display user profile
    }
}

class UserProfileUpdateService {
    func updateUserProfile(for user: User?) {
        // Code to update user profile
    }
}

class UserActivityLoggingService {
    func logUserActivity() {
        // Code to log user activity
    }
}

In the refactored example, we’ve created three separate services, each handling a single responsibility: displaying, updating, and logging. The UserProfileViewController now delegates these tasks to the respective services, adhering to SRP. This makes the code more modular, easier to maintain and sets the stage for better unit testing.

2. Open/Closed Principle (OCP)

The Open/Closed Principle (OCP) states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. Here’s a practical example of how you might apply OCP in an iOS app:

Before applying OCP:

Suppose you have a class that calculates the area of different shapes. Initially, it only supports rectangles.

class AreaCalculator {
    func calculateArea(ofRectangle rectangle: Rectangle) -> Double {
        return rectangle.width * rectangle.height
    }
}

struct Rectangle {
    var width: Double
    var height: Double
}

If you want to add support for circles, you’d have to modify the AreaCalculator class:

class AreaCalculator {
    func calculateArea(ofRectangle rectangle: Rectangle) -> Double {
        return rectangle.width * rectangle.height
    }
    
    func calculateArea(ofCircle circle: Circle) -> Double {
        return .pi * circle.radius * circle.radius
    }
}

struct Circle {
    var radius: Double
}

This violates the Open/Closed Principle because you are modifying the existing AreaCalculator class to add a new feature.

After applying OCP:

To comply with OCP, you can define a protocol for shapes that can have their area calculated and then extend each shape to conform to this protocol:

protocol Shape {
    func area() -> Double
}

struct Rectangle: Shape {
    var width: Double
    var height: Double
    
    func area() -> Double {
        return width * height
    }
}

struct Circle: Shape {
    var radius: Double
    
    func area() -> Double {
        return .pi * radius * radius
    }
}

class AreaCalculator {
    func calculateArea(of shape: Shape) -> Double {
        return shape.area()
    }
}

Now, AreaCalculator is closed for modification (you don't need to change it when adding new shapes) but open for extension (you can add new shapes by conforming to the Shape protocol). If you want to add a new shape like a triangle, you just need to create a new Triangle struct conforming to Shape without altering AreaCalculator:

struct Triangle: Shape {
    var base: Double
    var height: Double
    
    func area() -> Double {
        return 0.5 * base * height
    }
}

This way, the AreaCalculator class adheres to the Open/Closed Principle, making the codebase more robust and easier to maintain.

3. Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. Here’s an example that demonstrates LSP in iOS development:

Before applying LSP:

Suppose you have a base class Bird and a subclass Penguin. The Bird class has a method fly(), but since penguins can't fly, the subclass overrides this method with an empty implementation or throws an error. This violates LSP because a Penguin cannot be used as a substitute for a Bird in all cases.

class Bird {
    func fly() {
        print("Flying high")
    }
}

class Penguin: Bird {
    override func fly() {
        // Penguins can't fly, so this method either does nothing or throws an error
    }
}

func makeBirdFly(bird: Bird) {
    bird.fly()
}

let bird = Bird()
let penguin = Penguin()

makeBirdFly(bird: bird)       // Works fine
makeBirdFly(bird: penguin)    // Violates LSP, penguin can't fly

After applying LSP:

To adhere to LSP, you can refactor the classes so that the Bird class doesn't assume all birds can fly. Instead, create a separate protocol for flying behavior and have only the birds that can fly conform to it.

protocol Flyable {
    func fly()
}

class Bird {
    // Common bird properties and methods
}

class FlyingBird: Bird, Flyable {
    func fly() {
        print("Flying high")
    }
}

class Penguin: Bird {
    // Penguin specific properties and methods
}

func makeBirdFly(bird: Flyable) {
    bird.fly()
}

let eagle = FlyingBird()
let penguin = Penguin()

makeBirdFly(bird: eagle)      // Works fine
// makeBirdFly(bird: penguin) // This line will now cause a compile-time error, which is good because it prevents runtime errors and incorrect behavior.

In this refactored example, you have a Flyable protocol that only birds capable of flying conform to. The makeBirdFly function now takes a parameter that conforms to Flyable. This way, you can be sure that the bird passed to the function can indeed fly, and you won't mistakenly pass a Penguin to it. This adheres to LSP, as every bird that is considered Flyable can be used interchangeably without affecting the behavior of the program.

Another practical example for this case:

Let’s improve the example to better illustrate the Liskov Substitution Principle (LSP) in a more practical iOS context, such as a user interface scenario.

Before applying LSP:

Imagine you have a ViewController that displays different types of MediaContent. Each type of content has a method to be displayed, but some content types, like AudioContent, don't have a visual representation and therefore shouldn't be displayed in the same way as VideoContent or ImageContent.

class MediaContent {
    func displayContent() {
        print("Displaying content")
    }
}

class VideoContent: MediaContent {
    override func displayContent() {
        // Code to display video
        print("Displaying video")
    }
}

class ImageContent: MediaContent {
    override func displayContent() {
        // Code to display image
        print("Displaying image")
    }
}

class AudioContent: MediaContent {
    override func displayContent() {
        // Audio content doesn't have a visual representation
        print("Audio content can't be displayed visually")
    }
}

func displayMediaContent(content: MediaContent) {
    content.displayContent()
}

let video = VideoContent()
let image = ImageContent()
let audio = AudioContent()

displayMediaContent(content: video) // Works fine
displayMediaContent(content: image) // Works fine
displayMediaContent(content: audio) // Violates LSP, audio can't be displayed visually

After applying LSP:

To adhere to LSP, you can separate the concerns by creating a VisualContent protocol for content that has a visual representation and can be displayed. MediaContent will no longer assume all media can be displayed visually.

protocol VisualContent {
    func displayContent()
}

class MediaContent {
    // Common media properties and methods
}

class VideoContent: MediaContent, VisualContent {
    func displayContent() {
        // Code to display video
        print("Displaying video")
    }
}

class ImageContent: MediaContent, VisualContent {
    func displayContent() {
        // Code to display image
        print("Displaying image")
    }
}

class AudioContent: MediaContent {
    // Audio content specific properties and methods
}

func displayMediaContent(content: VisualContent) {
    content.displayContent()
}

let video = VideoContent()
let image = ImageContent()
let audio = AudioContent()

displayMediaContent(content: video) // Works fine
displayMediaContent(content: image) // Works fine
// displayMediaContent(content: audio) // This line will now cause a compile-time error

In this refactored example, by introducing the VisualContent protocol and having only VideoContent and ImageContent conform to it, you ensure that the displayMediaContent function only accepts content that can be visually displayed. Audio content, which does not have a visual representation, is not expected to adhere to this contract. This conforms to LSP, as every VisualContent passed to the function can be used interchangeably without any incorrect behavior.

By following LSP, you ensure that subclasses can stand in for their parent classes without the risk of runtime errors or unexpected behavior, which leads to more robust and reliable code.

4. Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) states that no client should be forced to depend on methods it does not use. Rather than one large interface, multiple specific interfaces are preferred based on groups of methods, each one serving one submodule.

Here’s a practical example demonstrating ISP in an iOS application:

Before applying ISP:

Imagine you have a protocol AllInOnePrinter that includes methods for printing, scanning, faxing, and photocopying.

protocol AllInOnePrinter {
    func printDocument(document: Document)
    func scanDocument(document: Document)
    func faxDocument(document: Document)
    func photocopyDocument(document: Document)
}

class Document {}

class MultiFunctionPrinter: AllInOnePrinter {
    func printDocument(document: Document) {
        // print the document
    }
    
    func scanDocument(document: Document) {
        // scan the document
    }
    
    func faxDocument(document: Document) {
        // fax the document
    }
    
    func photocopyDocument(document: Document) {
        // photocopy the document
    }
}

// This class only needs to print, but it's forced to implement all methods.
class SimplePrinter: AllInOnePrinter {
    func printDocument(document: Document) {
        // print the document
    }
    
    func scanDocument(document: Document) {
        // not supported, but must be implemented
    }
    
    func faxDocument(document: Document) {
        // not supported, but must be implemented
    }
    
    func photocopyDocument(document: Document) {
        // not supported, but must be implemented
    }
}

The SimplePrinter class is forced to implement methods for scanning, faxing, and photocopying, even though a simple printer might not have these capabilities. This violates ISP.

After applying ISP:

You can refactor the large interface into smaller, more specific interfaces.

protocol Printer {
    func printDocument(document: Document)
}

protocol Scanner {
    func scanDocument(document: Document)
}

protocol Fax {
    func faxDocument(document: Document)
}

protocol Photocopier {
    func photocopyDocument(document: Document)
}

class Document {}

class MultiFunctionPrinter: Printer, Scanner, Fax, Photocopier {
    func printDocument(document: Document) {
        // print the document
    }
    
    func scanDocument(document: Document) {
        // scan the document
    }
    
    func faxDocument(document: Document) {
        // fax the document
    }
    
    func photocopyDocument(document: Document) {
        // photocopy the document
    }
}

// This class now only implements the Printer protocol
class SimplePrinter: Printer {
    func printDocument(document: Document) {
        // print the document
    }
}

In this refactored example, each function (printing, scanning, faxing, photocopying) has its own protocol. The MultiFunctionPrinter class can implement all of these protocols, while the SimplePrinter class only implements the Printer protocol. This adheres to ISP and allows each class to only depend on the interfaces that are relevant to their function.

5. Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) states that 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.

Here’s an example demonstrating DIP in an iOS application:

Before applying DIP:

Imagine you have a UserSettingsViewController that directly depends on a specific UserDefaults storage implementation to save user settings.

import Foundation

class UserDefaultsStorage {
    func save(value: String, forKey key: String) {
        UserDefaults.standard.set(value, forKey: key)
    }
}

class UserSettingsViewController {
    let storage = UserDefaultsStorage()
    
    func saveUserSettings(value: String, forKey key: String) {
        storage.save(value: value, forKey: key)
    }
}

The UserSettingsViewController is directly dependent on the concrete UserDefaultsStorage class. This violates DIP because the high-level UserSettingsViewController is directly dependent on the low-level UserDefaultsStorage class.

After applying DIP:

To adhere to DIP, you would define an abstraction (protocol) for the storage mechanism and then depend on that abstraction instead of a concrete class.

import Foundation

protocol Storage {
    func save(value: String, forKey key: String)
}

class UserDefaultsStorage: Storage {
    func save(value: String, forKey key: String) {
        UserDefaults.standard.set(value, forKey: key)
    }
}

class UserSettingsViewController {
    let storage: Storage
    
    init(storage: Storage) {
        self.storage = storage
    }
    
    func saveUserSettings(value: String, forKey key: String) {
        storage.save(value: value, forKey: key)
    }
}

// Usage
let userDefaultStorage = UserDefaultsStorage()

let viewController = UserSettingsViewController(storage: userDefaultStorage)

Now, UserSettingsViewController depends on the Storage protocol, an abstraction, rather than the concrete UserDefaultsStorage. This allows you to easily swap out UserDefaultsStorage with another storage mechanism that conforms to the Storage protocol without changing the UserSettingsViewController code. It makes the code more flexible and easier to test.

For instance, you could create a mock storage for testing:

class MockStorage: Storage {
    func save(value: String, forKey key: String) {
        // Save the value in a way suitable for testing
    }
}

// Usage
let mockStorage = MockStorage()
let viewController = UserSettingsViewController(storage: mockStorage)

By following DIP, the UserSettingsViewController becomes more modular, easier to test, and adheres to good software design principles.

If you’re interested in learning how to apply the SOLID principles in Objective-C, continue to the following article for an in-depth discussion on the topic.

Swift
Swift Programming
Recommended from ReadMedium