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.

4.6 (28) · 3 Reviews

Download materials
Save for later
Share
You are currently viewing page 3 of 5 of this article. Click here to view the first page.

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.

The new weekly report type is visible only by adding it in the enum

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 uses ReportsDataSource.
  • 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.

Protocols group with ReportReader.swift and ExpenseModelProtocl.swift files inside it

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.

Monthly report after ExpenseModelProtocol refactoring

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")
  }
}
Note: Since you removed Core Data, each report reader instance will have its own snapshot of data when it's created. This means that when you add an expense from Today, you won't see it in Monthly unless you create a new report instance. The assertion ensures that you won't override this method in the subclass and the parent method isn't called by accident.

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.