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?
To write a great app, you not only have to come up with a great idea, but you also need to think about the future. The flexibility to adapt, improve and expand the features in your app quickly and efficiently is critical. Whether you’re working in a team or by yourself, how you write and organize your code will make a huge difference in maintaining your code in the long run. That’s where the SOLID principles come in.
Imagine you have a mess of paper on your desk. You might be able to find any given paper quickly but when someone else looks for something, it’s hard to find what they need. Your code is much like your desk, except that it’s even more likely that other people will need something from it.
If your desk were neat and organized, on the other hand, you’d have what developers refer to as clean code: Code that’s clear about what it does, maintainable and easy for others to understand. SOLID is a collection of principles that help you write clean code.
In this tutorial, you’ll:
- Learn the five principles of SOLID.
- Audit a working project that didn’t follow them.
- Update the project and see how much of a difference SOLID makes.
Since your goal is to learn how to improve your existing code, this SOLID tutorial assumes you already have a grasp of the basics of Swift and iOS.
Getting Started
Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial. Unzip it and open ExpenseTracker.xcodeproj in the starter folder.
The app allows users to store their expenses so they can track how much they spend daily or monthly.
Build and run the app. Try adding a few entries yourself:
The app works, but it isn’t in the best shape and it doesn’t follow the SOLID principles. Before you audit the project to identify its shortcomings, you should understand what those principles are.
Understanding SOLID’s Five Principles
The five principles of SOLID don’t directly relate to each other, but they all serve the same purpose: keeping code simple and clear.
These are the five SOLID principles:
- Single Responsibility
- Open-Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
Here’s an overview of what each principle means:
Single Responsibility
A class should have one, and only one, reason to change.
Each class or type you define should have only one job to do. That doesn’t mean you can only implement one method, but each class needs to have a focused, specialized role.
Open-Closed
Software entities, including classes, modules and functions, should be open for extension but closed for modification.
This means you should be able to expand the capabilities of your types without having to alter them drastically to add what you need.
Liskov Substitution
Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.
In other words, if you replace one object with another that’s a subclass and this replacement could break the affected part, then you’re not following this principle.
Interface Segregation
Clients should not be forced to depend upon interfaces they do not use.
When designing a protocol you’ll use in different places in your code, it’s best to break that protocol into multiple smaller pieces where each piece has a specific role. That way, clients depend only on the part of the protocol they need.
Dependency Inversion
Depend upon abstractions, not concretions.
Different parts of your code should not depend on concrete classes. They don’t need that knowledge. This encourages the use of protocols instead of using concrete classes to connect parts of your app.
Auditing the Project
The starter project breaks all five principles. It does work and, at a glance, it doesn’t feel very complicated or seem to require a lot of effort to maintain. If you look more closely, however, you’ll see that’s not true.
The easiest principle to spot being broken is dependency inversion. There are no protocols at all in the project, which means there are no interfaces to segregate, either.
Open AppMain.swift. All the Core Data setup takes place there, which doesn’t sound like a single responsibility at all. If you wanted to reuse the same Core Data setup in a different project, you’d find yourself taking pieces of code instead of the whole file.
Next, open ContentView.swift. This is the first view in the app, where you choose which kind of expense report you want to show: daily or monthly.
Say you wanted to add a report for the current week. With this setup, you’d need to create a new report screen to match DailyExpensesView
and MonthlyExpensesView
. Then, you’d alter ContentView
with a new list item and create a new DailyReportsDataSource
.
That’s quite messy and a lot of work just to add a variant of the functionality you already have. It’s safe to say that this is a violation of the open-closed principle.
Adding unit tests won’t be easy since almost all the modules are connected.
Additionally, if at some point you wanted to remove CoreData and replace it with something else, you’d need to change almost every file in this project. The simple reason for that is because everything is using the ManagedObject
subclass, ExpenseModel
.
Overall, the project gives minimum room for alteration. It focuses on the initial requirements and doesn’t allow for any future additions without considerable changes to the project as a whole.
Now, you’ll learn how you can apply each principle to clean up the project and see the benefits refactoring offers your app.
Invoking the Single Responsibility Principle
Open AppMain.swift again and look at the code. It has four main properties:
- container: The main persistence container for your app.
- previewContainer: A preview/mock container to use for SwiftUI previews. This eliminates the need for an actual database.
-
previewItem: This is a single item for previewing in
ExpenseItemView
. -
body: The body of the app itself. This is
AppMain
‘s main responsibility.
The only property you really need to have here is body
— the other three are out of place. Remove them and create a new Swift file named Persistence.swift in the Storage group.
In the new file, define a new struct named PersistenceController
:
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
}
This persistence controller is responsible for storing and retrieving data. shared
is a shared instance you’ll use across the app.
Within the new structure, add this property and initializer:
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "ExpensesModel")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(
fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { _, error in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
}
}
The parameter in the initializer defines whether the container will be temporary in memory or an actual container with a database file stored on the device. You’ll need in-memory storage for showing fake data in SwiftUI previews.
Next, define two new properties you’ll use for SwiftUI previews:
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for index in 1..<6 {
let newItem = ExpenseModel(context: viewContext)
newItem.title = "Test Title \(index)"
newItem.date = Date(timeIntervalSinceNow: Double(index * -60))
newItem.comment = "Test Comment \(index)"
newItem.price = Double(index + 1) * 12.3
newItem.id = UUID()
}
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
static let previewItem: ExpenseModel = {
let newItem = ExpenseModel(context: preview.container.viewContext)
newItem.title = "Preview Item Title"
newItem.date = Date(timeIntervalSinceNow: 60)
newItem.comment = "Preview Item Comment"
newItem.price = 12.34
newItem.id = UUID()
return newItem
}()
preview
is another instance of PersistenceController
similar to shared
, but the container
inside preview
doesn't read from a database file. Instead, it contains five expense entries that are hard-coded and stored in memory.
previewItem
is a single stub instance of ExpenseModel
, which is identical to the one you removed from AppMain.swift.
Why do all this? Currently, all of your app's classes use ExpenseModel
directly. You can't create an instance of this class without defining a persistent container. It's best to group properties related to Core Data setup and previews together.
Later in the refactoring, you'll be able to completely remove those preview support objects and replace them with something more organized.
static
properties are lazy by default. Until you use them, they'll never be allocated in memory. Because you only use them in previews, you shouldn't worry about them existing in memory at all.Using the New Persistence
Now that you've separated the Core Data setup from AppMain.swift, there are five locations you need to fix.
In DailyReportsDataSource.swift and MonthlyReportsDataSource.swift, change the default parameter in init(viewContext:)
to PersistenceController.shared.container.viewContext
, like so:
init(viewContext: NSManagedObjectContext
= PersistenceController.shared.container.viewContext
) {
self.viewContext = viewContext
prepare()
}
Then, in DailyExpensesView.swift and MonthlyExpensesView.swift locate the SwiftUI preview code. Change the parameter you send to the report's data source in previews
to PersistenceController.preview.container.viewContext
, like so:
let reportsDataSource = DailyReportsDataSource(
viewContext: PersistenceController.preview.container.viewContext)
and
let reportsDataSource = MonthlyReportsDataSource(
viewContext: PersistenceController.preview.container.viewContext)
Finally, in ExpenseItemView.swift's previews
, use the preview item PersistenceController.previewItem
instead of the one that you removed from AppMain
:
ExpenseItemView(expenseItem: PersistenceController.previewItem)
The previews of the DailyExpensesView
and MonthlyExpensesView
are identical and aren't affected by the refactor. The same applies for the preview of ExpenseItemView
.
Build and run. Open a report to make sure that your changes didn't break anything.
Implementing the Open-Closed Principle
The second principle is about structuring your code in a way that doesn't require that you make deep modifications in classes to add new features. A perfect example of how not to do this is the implementation of the daily and weekly reports.
Looking at DailyReportsDataSource.swift and MonthlyReportsDataSource.swift, you can see they're identical except for the dates the fetch request uses.
The same goes for DailyExpensesView.swift and MonthlyExpensesView.swift. They're also identical except for which report data source class they use.
Both cases use a lot of duplicate code — there's got to be a better way! :]
One option is to define a single data source class that uses a range of dates to fetch entries, then has a single view to display those entries.
To make it even cleaner, use an enum
to represent those ranges, then have ContentView
loop over the values in the enum
to populate the list of available options.
With this method, all you need to do to add a new report type is to create a new enum
. Everything else will just work. You'll implement this solution next.
Creating the Enum
In your Project navigator, create a new group named Enums. Create a new file inside it named ReportRange.swift.
In the new file, create a new enum
type:
enum ReportRange: String, CaseIterable {
case daily = "Today"
case monthly = "This Month"
}
CaseIterable
allows you to iterate over the possible values of the enum
you just defined. You'll use this option when you clean up ContentView
later.
Next, add the following within the definition of the enum
:
func timeRange() -> (Date, Date) {
let now = Date()
switch self {
case .daily:
return (now.startOfDay, now.endOfDay)
case .monthly:
return (now.startOfMonth, now.endOfMonth)
}
}
timeRange()
returns two dates in a tuple that represent a range. The first is the lower boundary and the second is the upper boundary. Based on the value of the enum
, it will return a range fitting either a day or a month.
Cleaning up the Reports
The next step is to merge the duplicate classes.
Completely delete MonthlyReportsDataSource.swift, then rename DailyReportsDataSource.swift to ReportsDataSource.swift. Also, rename the class inside it to match the file name.
To make Xcode do all the work, open DailyReportsDataSource.swift and right-click the class name. Choose Refactor ▸ Rename... from the pop-up menu. When you edit the name in one place, Xcode changes it everywhere else it occurs, including the filename. When you're finished editing the name, click Rename in the upper-right corner.
class ReportsDataSource: ObservableObject
Add a new property in the class to store the date range you want this instance to use:
let reportRange: ReportRange
Then, pass this value through the initializer by replacing the current initializer with the following one:
init(
viewContext: NSManagedObjectContext =
PersistenceController.shared.container.viewContext,
reportRange: ReportRange
) {
self.viewContext = viewContext
self.reportRange = reportRange
prepare()
}
Currently, the fetch request uses Date().startOfDay
and Date().endOfDay
. It should use the dates from the enum
instead. Change the implementation of getEntries()
to the following:
let fetchRequest: NSFetchRequest<ExpenseModel> =
ExpenseModel.fetchRequest()
fetchRequest.sortDescriptors = [
NSSortDescriptor(
keyPath: \ExpenseModel.date,
ascending: false)
]
let (startDate, endDate) = reportRange.timeRange()
fetchRequest.predicate = NSPredicate(
format: "%@ <= date AND date <= %@",
startDate as CVarArg,
endDate as CVarArg)
do {
let results = try viewContext.fetch(fetchRequest)
return results
} catch let error {
print(error)
return []
}
You declare two new variables in the method, startDate
and endDate
that you return inside the date range enum
. You then use those dates to filter all of the stored expenses inside the Core Data database. This way the displayed expenses adapt to the value of the date range you pass in the initializer of the class.
Similar to what you did for the data source files, delete the file MonthlyExpensesView.swift and rename DailyExpensesView.swift to ExpensesView.swift. Rename the class in the file to match the file name:
struct ExpensesView: View {
If you didn't choose to use Xcode's refactoring ability above, change the type of dataSource
to ReportsDataSource
:
@ObservedObject var dataSource: ReportsDataSource
Here, you use the more general data source you just created.
Finally, change all the SwiftUI preview code to the following:
struct ExpensesView_Previews: PreviewProvider {
static var previews: some View {
let reportsDataSource = ReportsDataSource(
viewContext: PersistenceController.preview
.container.viewContext,
reportRange: .daily)
ExpensesView(dataSource: reportsDataSource)
}
}
You added a reportRange
parameter to the data source's initializer, so you set it in the preview. For the SwiftUI preview, you'll always show the daily expenses.
Just by changing the data source type, you made the view more general. This shows how much code duplication there was in these two files.
Now, even though you created your general view, you're still not using it anywhere. You'll fix that soon.
Updating ContentView.swift
At this point, you only have a few remaining errors in ContentView.swift. Go to that file and start fixing them.
Completely remove the two calculated properties, dailyReport
and monthlyReport
, and add this new method instead:
func expenseView(for range: ReportRange) -> ExpensesView {
let dataSource = ReportsDataSource(reportRange: range)
return ExpensesView(dataSource: dataSource)
}
This will create the appropriate expense view for a given date range.
The SwiftUI list has two hard-coded NavigationLink
views for the two report types. If you want to add a new type of report, e.g. a weekly report, you'll have to change your code both here and in ReportRange
.
This is inefficient. You want to use all of ReportRange
's possible values to populate the list, without having to change code elsewhere.
Remove the content of List
and replace it with the following:
ForEach(ReportRange.allCases, id: \.self) { value in
NavigationLink(
value.rawValue,
destination: expenseView(for: value)
.navigationTitle(value.rawValue))
}
By making your enum
conform to CaseIterable
, you get access to the synthesized property allCases
. It gives you an array of all the values present in ReportRange
, thus allowing you to loop over them easily. For each enum
case, you'll create a new navigation link.
Finally, check the previews for ContentView and ExpensesView to ensure your refactoring didn't break anything.
Build and run, then check the reports you previously saved.
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.
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.
Auditing the App Again
Take another look at your app. With the changes you made, you resolved all the issues you identified in the first round:
- Core Data setup is no longer in
AppMain
and you separated it. - Your app doesn't rely on Core Data. It can now freely use any kind of storage with minimal changes to your code.
- Adding new report types is a matter of adding a new value in an
enum
. - Creating previews and tests is a lot easier than before, and you no longer need any complex mock objects.
There's a big improvement between how the project was before you started and how it is now. It didn't require much effort, and you reduced the amount of code as a side benefit.
Following SOLID isn't about executing a set of rules or about the setup of your architecture. Rather, SOLID gives you some guidelines to help you write code in a more organized way.
It makes fixing bugs safer since your objects aren't entangled. Writing unit tests is easier. Even reusing your code from one project to another is effortless.
Writing clean and organized code is a goal that always pays off. If you say, "I'll clean this up later", when that later moment comes, things will usually be too complicated to actually clean.
Where to Go From Here?
You can download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.
Using design patterns in your code offers simple solutions to what seems like a complex problem. Whether you know the fundamental iOS design patterns or not, it's always good to refresh your memory about them. Our Fundamental iOS Design Patterns tutorial can help.
Unit testing is a critical aspect of software development. Your tests need to focus on small parts of your code. Learn all about Dependency Injection to write great unit tests.
Another interesting concept that would improve the way you think about writing your apps is Defensive Programming. It's about having your code anticipate what might go wrong so your app isn't fragile and doesn't crash when it receives unexpected input.
A simple example of defensive coding is using guard let
instead of force unwrapping when you deal with optionals. Being aware of such topics improves the quality of your work without any additional effort.
We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!
All videos. All books.
One low price.
A Kodeco subscription is the best way to learn and master mobile development — plans start at just $19.99/month! Learn iOS, Swift, Android, Kotlin, Flutter and Dart development and unlock our massive catalog of 50+ books and 4,000+ videos.
Learn more