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?
Adding Weekly Reports
After those changes, adding another report type is easy. Try it out by adding a weekly report.
Open ReportRange.swift and add a new weekly value in the enum
, between daily and monthly:
case weekly = "This Week"
Inside timeRange()
, add the dates returned for this value:
case .weekly:
return (now.startOfWeek, now.endOfWeek)
Build and run. You'll immediately see the new item on the list.
Adding report types is simple now, requiring minimal effort. This is possible because your objects are smart. You didn't need to modify any of the internal implementations of ContentView
or ExpensesView
. This demonstrates how powerful the open-closed principle is.
For the remaining principles, you will go through them in a different order to make them simpler to apply. Remember, when you refactor an existing project, it isn't important to follow SOLID in order. It's important to do it right. :]
Applying Dependency Inversion
For your next step, you'll apply dependency inversion by breaking down dependencies into protocols. The current project has two concrete dependencies you need to break:
-
ExpensesView
directly usesReportsDataSource
. - The Core Data-managed object,
ExpenseModel
, indirectly makes everything using this class dependent on Core Data.
Instead of relying on a concrete implementation of these dependencies, you'll abstract them away by creating a protocol for each of the dependencies.
In the Project navigator, create a new group named Protocols and add two Swift files inside it: ReportReader.swift and ExpenseModelProtocol.swift.
Removing the Core Data Dependency
Open ExpenseModelProtocol.swift and create the following protocol:
protocol ExpenseModelProtocol {
var title: String? { get }
var price: Double { get }
var comment: String? { get }
var date: Date? { get }
var id: UUID? { get }
}
Next, in the Storage group, create a new file named ExpenseModel+Protocol.swift and make ExpenseModel
conform to the new protocol:
extension ExpenseModel: ExpenseModelProtocol { }
Notice that ExpenseModel
has the same property names as the protocol, so all you need to do to conform to the protocol is add an extension.
Now, you need to change the instances of code that used ExpenseModel
to use your new protocol instead.
Open ReportsDataSource.swift and change the type of currentEntries
to [ExpenseModelProtocol]
:
@Published var currentEntries: [ExpenseModelProtocol] = []
Then change the return type of getEntries()
to [ExpenseModelProtocol]
:
private func getEntries() -> [ExpenseModelProtocol] {
Next, open ExpenseItemView.swift and change the type of expenseItem
to ExpenseModelProtocol
:
let expenseItem: ExpenseModelProtocol
Build and run. Open any report and make sure that nothing broke in your app.
Seeing Your Changes in Action
The first bonus you get with this refactoring is the ability to mock an expense item without using PersistenceController.previewItem
. Open Persistence.swift and delete that property.
Now, open ExpenseItemView.swift and replace the SwiftUI preview code with the following:
struct ExpenseItemView_Previews: PreviewProvider {
struct PreviewExpenseModel: ExpenseModelProtocol {
var title: String? = "Preview Item"
var price: Double = 123.45
var comment: String? = "This is a preview item"
var date: Date? = Date()
var id: UUID? = UUID()
}
static var previews: some View {
ExpenseItemView(expenseItem: PreviewExpenseModel())
}
}
Previously, to show a mock expense, you had to set up a fake Core Data context and then store a model inside that context. That's a fairly complex endeavor just to show a few properties.
Now, the view depends on an abstract protocol, which you can implement with a Core Data model or just a plain old structure.
Additionally, if you decide to move away from Core Data and use some other storage solution, dependency inversion will let you easily swap out the underlying model implementation without having to change any code inside your views.
The same concept applies when you want to create unit tests. You can set up fake models to make sure your app works as expected with all kinds of different expenses.
The next part will allow you to eliminate the preview view context you're using to preview the reports.
Simplifying the Reports Datasource Interface
Before implementing the protocol in ReportReader.swift, there's something you should note.
Open ReportsDataSource.swift and check the declaration of the class and the declaration of its member property, currentEntries
class ReportsDataSource: ObservableObject {
@Published var currentEntries: [ExpenseModelProtocol] = []
}
ReportsDataSource
uses Combine's ObservableObject
to notify any observer of its published property, currentEntries
, whenever a new entry is added. Using @Published
requires a class; it can't be used in a protocol.
Open ReportReader.swift and create this protocol:
import Combine
protocol ReportReader: ObservableObject {
@Published var currentEntries: [ExpenseModelProtocol] { get }
func saveEntry(title: String, price: Double, date: Date, comment: String)
func prepare()
}
Xcode will complain with the error:
Property 'currentEntries' declared inside a protocol cannot have a wrapper.
But if you change this type to a class, Xcode will no longer complain:
class ReportReader: ObservableObject {
@Published var currentEntries: [ExpenseModelProtocol] = []
func saveEntry(
title: String,
price: Double,
date: Date,
comment: String
) { }
func prepare() {
assertionFailure("Missing override: Please override this method in the subclass")
}
}
Instead of creating a protocol that concrete implementations conform to, you'll create an abstract class that more concrete implementations need to subclass. It accomplishes the same goal: You can easily swap the underlying implementation without having to change any of your views.
Open ReportsDataSource.swift and change the declaration of the class to a subclass, ReportReader
, instead of conforming to ObservableObject
:
class ReportsDataSource: ReportReader {
Next, delete the declaration of currentEntries
. You don't need it anymore since you defined it in the superclass. Also, add the keyword override
for saveEntry(title:price:date:comment:)
and prepare()
:
override func saveEntry(
title: String, price: Double, date: Date, comment: String) {
override func prepare() {
Then, in init(viewContext:reportRange:)
, add the call to super.init()
right before the call to prepare()
:
super.init()
Navigate to ExpensesView.swift and you'll see that ExpenseView
uses ReportsDataSource
as the type of its data source. Change this type to the more abstract class you created, ReportReader
:
@ObservedObject var dataSource: ReportReader
By simplifying your dependencies like this, you can safely clean up the preview code of ExpenseView.