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:
- 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.
- Flexibility: By following the Open/Closed Principle, you can add new functionality without altering existing code, which means less chance of breaking existing functionality.
- 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.
- Scalability: As your application grows, SOLID principles help manage complexity by organizing code into manageable chunks that follow clear contracts.
- 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.