
The SOLID principles are a set of five fundamental principles that aim to make object-oriented code more maintainable, flexible, and easier to extend and modify. The SOLID acronym stands for:
- Single Responsibility Principle (SRP)
- Open-Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
In this post, we will explore how to apply each SOLID principle in Swift with practical examples that you can immediately use in your iOS project.
Single Responsibility Principle (SRP)
This principle states that a class should have only one reason to change, meaning it should focus on a single responsibility or concern. This prevents classes from becoming bloated with unrelated functionality, making them easier to understand, test, and maintain.
In iOS development, an app often involve multiple layers like UI, networking, and data persistence. Violating SRP can lead to “god classes” that handle everything from fetching data to updating the UI, which becomes a nightmare during refactoring or when adding features.
Here’s an example of violating SRP:
class UserManager { func registerUser(email: String, password: String) -> Bool { // Register user logic... } func sendWelcomeEmail(to: String, user: User) { // Send welcome email logic... } func saveUserData(user: User) { // Save user data logic... } }
Here, the UserManager has multiple responsibilities: registering users, sending welcome emails, and saving user data. Now, applying SRP, we refactor it into focused classes:
class UserRegistration { func registerUser(email: String, password: String) -> Bool { // Register user logic... } } class EmailService { func sendWelcomeEmail(to: String, user: User) { // Send welcome email logic... } } class UserDataRepository { func saveUserData(user: User) { // Save user data logic... } }
In this refactored version, each class has a single responsibility: user registration, email handling, and user data management. If we need to change the welcome email template, only EmailService needs updating, keeping changes isolated.
Open-Closed Principle (OCP)
According to this principle, software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means that the behavior of a module can be extended without modifying its source code, which helps in managing changes and in promoting reusability.
This is particularly useful in Swift for handling evolving requirements, like adding new payment methods in an e-commerce app without rewriting the core payment processor. Protocols and extensions in Swift make OCP straightforward to implement.
Imagine a payment processing system. A naive implementation might use a switch statement that you’d have to modify for each new payment type:
class PaymentProcessor { func process(paymentType: String, amount: Double) { switch paymentType { case "creditCard": print("Processing credit card: \(amount)") case "paypal": print("Processing PayPal: \(amount)") default: print("Unsupported payment type") } } }
To apply OCP, we use a protocol and conformant classes, allowing extension via new classes without touching the processor:
protocol PaymentMethod { func process(amount: Double) } class CreditCard: PaymentMethod { func process(amount: Double) { print("Processing credit card: \(amount)") } } class PayPal: PaymentMethod { func process(amount: Double) { print("Processing PayPal: \(amount)") } } class PaymentProcessor { func process(method: PaymentMethod, amount: Double) { method.process(amount: amount) } } // Later, add a new method without modifying PaymentProcessor class ApplePay: PaymentMethod { func process(amount: Double) { print("Processing Apple Pay: \(amount)") } }
Liskov Substitution Principle (LSP)
This principle, named after Barbara Liskov, asserts that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In other words, subclasses should honor the contracts of their superclasses, avoiding unexpected behavior.
For instance, consider a base Bird class with a fly method. A subclass like Penguin that throws an error when flying violates LSP because it can’t substitute for Bird seamlessly.
class Bird { func fly() { print("Flying...") } } class Penguin: Bird { override func fly() { fatalError("Penguins can't fly!") } } func makeBirdFly(bird: Bird) { bird.fly() // Crashes if bird is Penguin }
Here’s how we can fix it.
protocol Flyable { func fly() } class Eagle: Flyable { func fly() { print("Eagle flying high...") } } class Penguin { func swim() { print("Penguin swimming...") } } func makeFly(flyable: Flyable) { flyable.fly() // Safe, only called on Flyable types }
Now, Penguin isn’t forced into a flying contract, ensuring substitutions work correctly.
Interface Segregation Principle (ISP)
This principle advocates that clients should not be forced to depend upon interfaces they do not use. By keeping interfaces small and specific, this principle helps to reduce the side effects of changes and enhances component reusability.
Let’s see an example that violates ISP
protocol Worker { func work() func eat() } class HumanWorker: Worker { func work() { print("Human working") } func eat() { print("Human eating lunch") } } class RobotWorker: Worker { func work() { print("Robot working") } func eat() { // Irrelevant for robots } }
The Worker protocol forces RobotWorker to implement an eat() method, which is irrelevant for robots. This forces implementers to handle methods they do not need, cluttering the class’s interface with unnecessary implementations.
Applying ISP, we split the Worker protocol into Workable for working and Eatable for eating, classes can choose to implement only the interfaces that are relevant to them. This makes our class designs cleaner and more modular, as each class only agrees to perform actions that are relevant to its context.
protocol Workable { func work() } protocol Eatable { func eat() } class HumanWorker: Workable, Eatable { func work() { print("Human working") } func eat() { print("Human eating lunch") } } class RobotWorker: Workable { func work() { print("Robot working") } }
Dependency Inversion Principle (DIP)
The Dependency Inversion Principle involves two key points:
- High-level modules should not depend on low-level modules, but both should depend on abstractions.
- Abstractions should not depend upon details, but details should depend on abstractions.
This principle promotes loose coupling between components and makes it easier to swap out implementations without affecting the rest of the system.
The DataManager in the below example is directly dependent on a low-level module LowLevelStorage, making it difficult to replace the storage mechanism without modifying the DataManager class.
class LowLevelStorage { func store(data: String) { print("Data stored on disk") } } class DataManager { private let storage = LowLevelStorage() func saveData(data: String) { storage.store(data: data) } }
A better approach would be to introduce an abstraction and inject the dependency:
protocol Storage { func store(data: String) } class LowLevelStorage: Storage { func store(data: String) { print("Data stored on disk") } } class DataManager { private let storage: Storage init(storage: Storage) { self.storage = storage } func saveData(data: String) { storage.store(data: data) } }
Here, we decouple DataManager from the concrete implementation of storage. DataManager now relies on the abstraction (Storage), not on the details (specific storage type). This allows for easy substitution of different storage strategies without changing the DataManager code.
Conclusion
Incorporating SOLID principles into your app development workflow is not just about following rules—it’s about writing Swift code that is easier to maintain, extend, and understand. While it might take time to build the habit of thinking in SOLID terms, the payoff is significant: fewer bugs, cleaner designs, and code that future you (and your teammates) will thank you for.
Thanks for reading! 🚀