SOLID Principles Applied to Swift
Learn to apply the five SOLID principles to the Swift programming language for clean code and a reusable, maintainable component for mobile development.
Join the DZone community and get the full member experience.
Join For FreeSOLID is an acronym created by Robert C. Martin (Uncle Bob). It represents five principles of object-oriented programming: Single responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion.
Thanks to these principles, you can solve the main problems of a bad architecture:
- Fragility: A change may break unexpected parts—it is very difficult to detect if you don’t have good test coverage.
- Immobility: A component is difficult to reuse in another project—or in multiple places of the same project—because it has too many coupled dependencies.
- Rigidity: A change requires a lot of efforts because affects several parts of the project.
Of course, as Uncle Bob pointed out in his article that these are not strict rules, but guidelines to improve the quality of your architecture.
Principles will not turn a bad programmer into a good programmer. Principles have to be applied with judgment. If they are applied by rote, it is just as bad as if they are not applied at all. You must be smart enough to understand when to apply these principles.
Principles
The Single Responsibility Principle (SRP)
"There should never be more than one reason for a class to change." -SRP: The Single Responsibility Principle
Every time you create/change a class, you should ask yourself: How many responsibilities does this class have?
Let’s see an example:
class Handler {
func handle() {
let data = requestDataToAPI()
let array = parse(data: data)
saveToDB(array: array)
}
private func requestDataToAPI() -> Data {
// send API request and wait the response
}
private func parse(data: Data) -> [String] {
// parse the data and create the array
}
private func saveToDB(array: [String]) {
// save the array in a database (CoreData/Realm/...)
}
}
How many responsibilities does this class have?
Handler
retrieves the data from the API (1), parses the API response, creating an array of String
, (2) and saves the array in a database (3).
Once you consider that you have to use in the same class Alamofire for the API request, ObjectMapper for the parsing and the CoreData stack to save the data in the database, you will start understanding the smell of this class.
You can solve this problem moving the responsibilities down to little classes:
class Handler {
let apiHandler: APIHandler
let parseHandler: ParseHandler
let dbHandler: DBHandler
init(apiHandler: APIHandler, parseHandler: ParseHandler, dbHandler: DBHandler) {
self.apiHandler = apiHandler
self.parseHandler = parseHandler
self.dbHandler = dbHandler
}
func handle() {
let data = apiHandler.requestDataToAPI()
let array = parseHandler.parse(data: data)
dbHandler.saveToDB(array: array)
}
}
class APIHandler {
func requestDataToAPI() -> Data {
// send API request and wait the response
}
}
class ParseHandler {
func parse(data: Data) -> [String] {
// parse the data and create the array
}
}
class DBHandler {
func saveToDB(array: [String]) {
// save the array in a database (CoreData/Realm/...)
}
}
This principle helps you to keep your classes as clean as possible. Moreover, in the first example you couldn’t test requestDataToAPI
, parse
and saveToDB
directly, since those were private methods. After the refactor, you can easily do it testing APIHandler
, ParseHandler
and DBHandler
.
The Open-Closed Principle (OCP)
"Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification." -The Open-Closed Principle
If you want to create a class that is easy to maintain, it must have two important characteristics:
- Open for extension: You should be able to extend or change the behaviors of a class without efforts.
- Closed for modification: You must extend a class without changing the implementation.
You can achieve these characteristics thanks to the abstraction.
As an example, we have a class Logger
which iterates an array of Cars
and prints the details of each car:
class Logger {
func printData() {
let cars = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey")
]
cars.forEach { car in
print(car.printDetails())
}
}
}
class Car {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
If you want to add the possibility to print also the details of a new class, we should change the implementation of printData
every time we want to log a new class—breaking OCP:
class Logger {
func printData() {
let cars = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey")
]
cars.forEach { car in
print(car.printDetails())
}
let bicycles = [
Bicycle(type: "BMX"),
Bicycle(type: "Tandem")
]
bicycles.forEach { bicycles in
print(bicycles.printDetails())
}
}
}
class Car {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
class Bicycle {
let type: String
init(type: String) {
self.type = type
}
func printDetails() -> String {
return "I'm a \(type)"
}
}
We can solve this problem creating a new protocol Printable
, which will be implemented by the classes to log. Finally, printData
will print an array of Printable
.
In this way, we create a new abstract layer between printData
and the class to log, allowing the print of other classes like Bicycle
and without changing the printData
implementation.
protocol Printable {
func printDetails() -> String
}
class Logger {
func printData() {
let cars: [Printable] = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey"),
Bicycle(type: "BMX"),
Bicycle(type: "Tandem")
]
cars.forEach { car in
print(car.printDetails())
}
}
}
class Car: Printable {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
class Bicycle: Printable {
let type: String
init(type: String) {
self.type = type
}
func printDetails() -> String {
return "I'm a \(type)"
}
}
The Liskov Substitution Principle (LSP)
"Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it." -The Liskov Substitution Principle
Inheritance may be dangerous and you should use composition over inheritance to avoid a messy codebase, even more so if you use inheritance in an improper way.
This principle can help you to use inheritance without messing it up. Let’s see the main problems which break LSP:
Preconditions Changes
We have a class Handler
, it has the responsibility to save a string in a cloud service. Then, the business logic changes, and, sometimes, you must save the string just if its length is greater than five. Therefore, we decide to create a subclass FilteredHandler
:
class Handler {
func save(string: String) {
// Save string in the Cloud
}
}
class FilteredHandler: Handler {
override func save(string: String) {
guard string.characters.count > 5 else { return }
super.save(string: string)
}
}
This example breaks LSP because, in the subclass, we add the precondition that string
must have a length greater than 5. A client of Handler
doesn’t expect that FilteredHandler
has a different precondition, since it should be the same for Handler
and all its subclasses.
We can solve this problem getting rid of FilteredHandler
and adding a new parameter to inject the minimum length of characters to filter:
class Handler {
func save(string: String, minChars: Int = 0) {
guard string.characters.count >= minChars else { return }
// Save string in the Cloud
}
}
Postconditions Changes
We have a project where we must compute the area of some rectangle objects—so we create the class Rectangle
. After a couple of months, we need to compute also the area of square objects—so we decide to create a subclass Square
. Since in a square we need just a side to compute the area—and we don’t want to override the computation of area
—we decide to assign the same value of width
to length
:
class Rectangle {
var width: Float = 0
var length: Float = 0
var area: Float {
return width * length
}
}
class Square: Rectangle {
override var width: Float {
didSet {
length = width
}
}
}
With this approach, we break LSP because if the client has the current method:
func printArea(of rectangle: Rectangle) {
rectangle.length = 5
rectangle.width = 2
print(rectangle.area)
}
The result should always be the same in the both calls:
let rectangle = Rectangle()
printArea(of: rectangle) // 10
// -------------------------------
let square = Square()
printArea(of: square) // 4
Instead, the first one prints 10
and the second one 4
. This means that, with this inheritance, we have just broken the postcondition of the width
setter which is: ((width == newValue) && (height == height))
.
We can solve it using a protocol with a method area
, implemented by Rectangle
and Square
in different ways. Finally, we change the printArea
parameter type to accept an object which implement this protocol:
protocol Polygon {
var area: Float { get }
}
class Rectangle: Polygon {
private let width: Float
private let length: Float
init(width: Float, length: Float) {
self.width = width
self.length = length
}
var area: Float {
return width * length
}
}
class Square: Polygon {
private let side: Float
init(side: Float) {
self.side = side
}
var area: Float {
return pow(side, 2)
}
}
// Client Method
func printArea(of polygon: Polygon) {
print(polygon.area)
}
// Usage
let rectangle = Rectangle(width: 2, length: 5)
printArea(of: rectangle) // 10
let square = Square(side: 2)
printArea(of: square) // 4
The Interface Segregation Principle (ISP)
"Clients should not be forced to depend upon interfaces that they do not use." -The Interface Segregation Principle
This principle introduces one of the problems of object-oriented programming: the fat interface.
A interface is called “fat” when has too many members/methods, which are not cohesive and contain more information than we really want. This problem can affect both classes and protocols.
Fat Interface (Protocol)
We start with the protocol GestureProtocol
with a method didTap
:
protocol GestureProtocol {
func didTap()
}
After some time, you have to add new gestures to the protocol and it becomes:
protocol GestureProtocol {
func didTap()
func didDoubleTap()
func didLongPress()
}
Our SuperButton
is happy to implement the methods which it needs:
class SuperButton: GestureProtocol {
func didTap() {
// send tap action
}
func didDoubleTap() {
// send double tap action
}
func didLongPress() {
// send long press action
}
}
The problem is that our app has also a PoorButton
which needs just didTap
. It must implement methods which it doesn’t need, breaking ISP:
class PoorButton: GestureProtocol {
func didTap() {
// send tap action
}
func didDoubleTap() { }
func didLongPress() { }
}
We can solve the problem using little protocols instead of a big one:
protocol TapProtocol {
func didTap()
}
protocol DoubleTapProtocol {
func didDoubleTap()
}
protocol LongPressProtocol {
func didLongPress()
}
class SuperButton: TapProtocol, DoubleTapProtocol, LongPressProtocol {
func didTap() {
// send tap action
}
func didDoubleTap() {
// send double tap action
}
func didLongPress() {
// send long press action
}
}
class PoorButton: TapProtocol {
func didTap() {
// send tap action
}
}
Fat Interface (Class)
We can use, as an example, an application which has a collection of playable videos. This app has the class Video
which represents a video of the user’s collection:
class Video {
var title: String = "My Video"
var description: String = "This is a beautiful video"
var author: String = "Marco Santarossa"
var url: String = "https://marcosantadev.com/my_video"
var duration: Int = 60
var created: Date = Date()
var update: Date = Date()
}
And we inject it in the video player:
func play(video: Video) {
// load the player UI
// load the content at video.url
// add video.title to the player UI title
// update the player scrubber with video.duration
}
Unfortunately, we are injecting too much information in the method play
, since it needs just url
, title
and duration
.
You can solve this problem using a protocol Playable
with just the information the player needs:
protocol Playable {
var title: String { get }
var url: String { get }
var duration: Int { get }
}
class Video: Playable {
var title: String = "My Video"
var description: String = "This is a beautiful video"
var author: String = "Marco Santarossa"
var url: String = "https://marcosantadev.com/my_video"
var duration: Int = 60
var created: Date = Date()
var update: Date = Date()
}
func play(video: Playable) {
// load the player UI
// load the content at video.url
// add video.title to the player UI title
// update the player scrubber with video.duration
}
This approach is very useful also for the unit test. We can create a stub class which implements the protocol Playable
:
class StubPlayable: Playable {
private(set) var isTitleRead = false
var title: String {
self.isTitleRead = true
return "My Video"
}
var duration = 60
var url: String = "https://marcosantadev.com/my_video"
}
func test_Play_IsUrlRead() {
let stub = StubPlayable()
play(video: stub)
XCTAssertTrue(stub.isTitleRead)
}
The Dependency Inversion Principle (DIP)
"A. High-level modules should not depend upon low-level modules. Both should depend upon abstractions. B. Abstractions should not depend upon details. Details should depend upon abstractions." -The Dependency Inversion Principle
This principle is the right one to follow if you believe in reusable components.
DIP is very similar to Open-Closed Principle: the approach to use, to have a clean architecture, is decoupling the dependencies. You can achieve it thanks to abstract layers.
Let’s consider the class Handler
, which saves a string in the filesystem. It calls, internally, FilesystemManager
which manages how to save the string in the filesystem:
class Handler {
let fm = FilesystemManager()
func handle(string: String) {
fm.save(string: string)
}
}
class FilesystemManager {
func save(string: String) {
// Open a file
// Save the string in this file
// Close the file
}
}
FilesystemManager
is the low-level module and it’s easy to reuse in other projects. The problem is the high-level module Handler
, which is not reusable because is tightly coupled with FilesystemManager
. We should be able to reuse the high-level module with different kind of storages like a database, cloud, and so on.
We can solve this dependency using a protocol Storage
. In this way, Handler
can use this abstract protocol without caring of the kind of storage used. With this approach, we can change easily from a filesystem to a database:
class Handler {
let storage: Storage
init(storage: Storage) {
self.storage = storage
}
func handle(string: String) {
storage.save(string: string)
}
}
protocol Storage {
func save(string: String)
}
class FilesystemManager: Storage {
func save(string: String) {
// Open a file in read-mode
// Save the string in this file
// Close the file
}
}
class DatabaseManager: Storage {
func save(string: String) {
// Connect to the database
// Execute the query to save the string in a table
// Close the connection
}
}
This principle is very useful also for testing. You can easily use a stub class—which implements Storage
—and test if handle
calls the method save
of the Storage
object injected:
class StubStorage: Storage {
var isSavedCalled = false
func save(string: String) {
isSavedCalled = true
}
}
class HandlerTests {
func test_Handle_IsSaveCalled() {
let handler = Handler()
let stubStorage = StubStorage()
handler.handle(string: "test", storage: stubStorage)
XCTAssertTrue(stubStorage.isSavedCalled)
}
}
If you follow SOLID principles judiciously, you can increase the quality of your code. Moreover, your components can become more maintainable and reusable.
The mastering of these principles is not the last step to become a perfect developer; actually, it’s just the beginning. You will have to deal with different problems in your projects, understand the best approach and, finally, check if you’re breaking some principles.
You have three enemies to defeat: Fragility, Immobility, and Rigidity. SOLID principles are your weapons. Enjoy!
Published at DZone with permission of Marco Santarossa, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments