SOLID Principles for iOS Apps
SOLID is a group of principles that lead you to write clear and organized code without additional effort. Learn how to apply it to your SwiftUI iOS apps. By Ehab Amer.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
SOLID Principles for iOS Apps
40 mins
- Getting Started
- Understanding SOLID’s Five Principles
- Single Responsibility
- Open-Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
- Auditing the Project
- Invoking the Single Responsibility Principle
- Using the New Persistence
- Implementing the Open-Closed Principle
- Creating the Enum
- Cleaning up the Reports
- Updating ContentView.swift
- Adding Weekly Reports
- Applying Dependency Inversion
- Removing the Core Data Dependency
- Seeing Your Changes in Action
- Simplifying the Reports Datasource Interface
- Refactoring ExpensesView
- Adding Interface Segregation
- Splitting up Protocols
- Implementing Liskov Substitution
- Auditing the App Again
- Where to Go From Here?
Refactoring ExpensesView
Add a new structure definition inside ExpensesView_Previews
:
struct PreviewExpenseEntry: ExpenseModelProtocol {
var title: String?
var price: Double
var comment: String?
var date: Date?
var id: UUID? = UUID()
}
Similar to what you defined earlier inside ExpenseItemView
, this is a basic model that you can use as a mock expense item.
Next, add a class right underneath the structure you just added:
class PreviewReportsDataSource: ReportReader {
override init() {
super.init()
for index in 1..<6 {
saveEntry(
title: "Test Title \(index)",
price: Double(index + 1) * 12.3,
date: Date(timeIntervalSinceNow: Double(index * -60)),
comment: "Test Comment \(index)")
}
}
override func prepare() {
}
override func saveEntry(
title: String,
price: Double,
date: Date,
comment: String
) {
let newEntry = PreviewExpenseEntry(
title: title,
price: price,
comment: comment,
date: date)
currentEntries.append(newEntry)
}
}
This is a simplified data source that keeps all records in memory. It starts with a few records instead of being empty, just like the ReportsDataSource
, but it eliminates the need for Core Data and to initialize a preview context.
Finally, change the implementation of the preview to the following:
static var previews: some View {
ExpensesView(dataSource: PreviewReportsDataSource())
}
Here, you tell the preview to use the data source you just created.
Finally, open Persistence.swift and remove the last trace of preview objects by removing preview
. Your views are no longer tied to Core Data. This not only lets you delete the code you wrote here, but also allows you to easily provide a mock data source to your views inside your tests.
Build and run. You'll find everything is still intact and unaffected, and the preview now shows your mock expenses.
Adding Interface Segregation
Look at AddExpenseView
and you'll see that it expects a closure to save the entry. Currently, ExpensesView
now provides this closure. All it does is call a method on ReportReader
.
An alternative is to pass the data source to AddExpenseView
so it can call the method directly.
The obvious difference between the two approaches is: ExpensesView
has the responsibility of informing AddExpenseView
how to perform a save.
If you modify the fields you're saving, you'll need to propagate this change to both views. However, if you pass the data source directly, the listing view won't be responsible for any of the details about how information is saved.
But this approach will make the other functionalities provided by ReportReader
visible to AddExpenseView
.
The SOLID principle of interface segregation recommends that you separate interfaces into smaller pieces. This keeps each client focused on its main responsibility and avoids confusion.
In this case, the principle indicates that you should separate saveEntry(title:price:date:comment:)
into its own protocol, then have ReportsDataSource
conform to that protocol.
Splitting up Protocols
In the Protocols group, create a new Swift file and name it SaveEntryProtocol.swift. Add the following protocol to the new file:
protocol SaveEntryProtocol {
func saveEntry(
title: String,
price: Double,
date: Date,
comment: String)
}
Open ReportReader.swift and remove saveEntry(title:price:date:comment:)
.
Next, open ReportsDataSource.swift and change the declaration of the class to conform to your new protocol:
class ReportsDataSource: ReportReader, SaveEntryProtocol {
Since you're now implementing a protocol method and not overriding the method from a superclass, remove the override
keyword from saveEntry(title:price:date:comment)
.
Do the same in PreviewReportsDataSource
in ExpensesView.swift. First, add the conformance:
class PreviewReportsDataSource: ReportReader, SaveEntryProtocol {
Then, remove the override
keyword as before.
Both of your data sources now conform to your new protocol, which is very specific about what it does. All that's left is to change the rest of your code to use this protocol.
Open AddExpenseView.swift and replace saveClosure
with:
var saveEntryHandler: SaveEntryProtocol
Now, you're using the protocol instead of the closure.
In saveEntry()
, replace the call to saveClosure
with the new property you just added:
saveEntryHandler.saveEntry(
title: title,
price: numericPrice,
date: time,
comment: comment)
Change the SwiftUI preview code to match your changes:
struct AddExpenseView_Previews: PreviewProvider {
class PreviewSaveHandler: SaveEntryProtocol {
func saveEntry(title: String, price: Double, date: Date, comment: String) {
}
}
static var previews: some View {
AddExpenseView(saveEntryHandler: PreviewSaveHandler())
}
}
Finally, open ExpensesView.swift and change the full screen cover for $isAddPresented
to the following:
.fullScreenCover(isPresented: $isAddPresented) { () -> AddExpenseView? in
guard let saveHandler = dataSource as? SaveEntryProtocol else {
return nil
}
return AddExpenseView(saveEntryHandler: saveHandler)
}
Now, you're using the more explicit and specific protocol to save your expenses. If you continue working on this project, you will almost certainly want to change and add to the saving behavior. For example, you might want to want to change database frameworks, add synchronization across devices or add a server-side component.
Having specific protocols like this will make it easy to change features in the future and will make testing those new features much easier. It's better to do this now, when you have a small amount of code, than to wait until the project gets too big and crusty.
Implementing Liskov Substitution
Currently, AddExpenseView
expects any saving handler to be able to save. Furthermore, it doesn't expect the saving handler to do anything else.
If you present AddExpenseView
with another object that conforms to SaveEntryProtocol
but performs some validations before storing the entry, it will affect the overall behavior of the app because AddExpenseView
doesn't expect this behavior. This stands against the Liskov Substitution principle.
That doesn't mean your initial SaveEntryProtocol
design was incorrect. This situation is likely to occur as your app grows and more requirements come in. But as it grows, you should understand how to refactor your code in a way that doesn't allow another implementation to violate the expectations of the object using it.
For this app, all you need to do is to allow saveEntry(title:price:date:comment:)
to return a Boolean to confirm whether it saved the value.
Open SaveEntryProtocol.swift and add a return value to the method's definition:
func saveEntry(
title: String,
price: Double,
date: Date,
comment: String
) -> Bool
Update ReportsDataSource.swift to match the changes in the protocol. First, add the return type to saveEntry(title:price:date:comment:)
:
func saveEntry(
title: String,
price: Double,
date: Date,
comment: String
) -> Bool {
Next, return true
at the end of the method.
return true
Perform those two steps again in the following places:
-
AddExpenseView_Previews.PreviewSaveHandler
in AddExpenseView.swift -
ExpensesView_Previews
in ExpensesView.swift
Next, in AddExpenseView.swift, replace the saveEntryHandler
method call in saveEntry()
with the following:
guard saveEntryHandler.saveEntry(
title: title,
price: numericPrice,
date: time,
comment: comment)
else {
print("Invalid entry.")
return
}
If the entry validation fails, you'll exit from the method early, bypassing the dismissal of the view. This way, AddExpenseView
won't dismiss if the save method returns false
.
Eliminate the final warning in ExpensesView.swift by changing the line saveEntry(
to:
_ = saveEntry(
This discards the unused return value.