9.
Builder Pattern
Written by Joshua Greene
The builder pattern allows you to create complex objects by providing inputs step-by-step, instead of requiring all inputs upfront via an initializer. This pattern involves three main types:
-
The director accepts inputs and coordinates with the builder. This is usually a view controller or a helper class that’s used by a view controller.
-
The product is the complex object to be created. This can be either a struct or a class, depending on desired reference semantics. It’s usually a model, but it can be any type depending on your use case.
-
The builder accepts step-by-step inputs and handles the creation of the product. This is often a class, so it can be reused by reference.
When should you use it?
Use the builder pattern when you want to create a complex object using a series of steps.
This pattern works especially well when a product requires multiple inputs. The builder abstracts how these inputs are used to create the product, and it accepts them in whatever order the director wants to provide them.
For example, you can use this pattern to implement a “hamburger builder.” The product could be a hamburger model, which has inputs such as meat selection, toppings and sauces. The director could be an employee object, which knows how to build hamburgers, or it could be a view controller that accepts inputs from the user.
The “hamburger builder” can thereby accept meat selection, toppings and sauces in any order and create a hamburger upon request.
Playground example
Open FundamentalDesignPattern.xcworkspace in the Starter directory, or continue from your own playground workspace from the last chapter, and then open the Overview page.
You’ll see Builder is listed under Creational Patterns. This is because this pattern is all about creating complex products. Click on the Builder link to open that page.
You’ll implement the “hamburger builder” example from above. You first need to define the product. Enter the following right after Code Example:
import Foundation
// MARK: - Product
// 1
public struct Hamburger {
public let meat: Meat
public let sauce: Sauces
public let toppings: Toppings
}
extension Hamburger: CustomStringConvertible {
public var description: String {
return meat.rawValue + " burger"
}
}
// 2
public enum Meat: String {
case beef
case chicken
case kitten
case tofu
}
// 3
public struct Sauces: OptionSet {
public static let mayonnaise = Sauces(rawValue: 1 << 0)
public static let mustard = Sauces(rawValue: 1 << 1)
public static let ketchup = Sauces(rawValue: 1 << 2)
public static let secret = Sauces(rawValue: 1 << 3)
public let rawValue: Int
public init(rawValue: Int) {
self.rawValue = rawValue
}
}
// 4
public struct Toppings: OptionSet {
public static let cheese = Toppings(rawValue: 1 << 0)
public static let lettuce = Toppings(rawValue: 1 << 1)
public static let pickles = Toppings(rawValue: 1 << 2)
public static let tomatoes = Toppings(rawValue: 1 << 3)
public let rawValue: Int
public init(rawValue: Int) {
self.rawValue = rawValue
}
}
Taking each commented section in turn:
-
You first define
Hamburger, which has properties formeat,sauceandtoppings. Once a hamburger is made, you aren’t allowed to change its components, which you codify vialetproperties. You also makeHamburgerconform toCustomStringConvertible, so you can print it later. -
You declare
Meatas anenum. Each hamburger must have exactly one meat selection: sorry, no beef-chicken-tofu burgers allowed. You also specify an exotic meat,kitten. Who doesn’t like nom nom kitten burgers? -
You define
Saucesas anOptionSet. This will allow you to combine multiple sauces together. My personal favorite is ketchup-mayonnaise-secret sauce. -
You likewise define
Toppingsas anOptionSet. You’re gonna need more than pickles for a good burger!
Next, add the following code to define the builder:
// MARK: - Builder
public class HamburgerBuilder {
// 1
public private(set) var meat: Meat = .beef
public private(set) var sauces: Sauces = []
public private(set) var toppings: Toppings = []
// 2
public func addSauces(_ sauce: Sauces) {
sauces.insert(sauce)
}
public func removeSauces(_ sauce: Sauces) {
sauces.remove(sauce)
}
public func addToppings(_ topping: Toppings) {
toppings.insert(topping)
}
public func removeToppings(_ topping: Toppings) {
toppings.remove(topping)
}
public func setMeat(_ meat: Meat) {
self.meat = meat
}
// 3
public func build() -> Hamburger {
return Hamburger(meat: meat,
sauce: sauces,
toppings: toppings)
}
}
There are a few important subtleties here:
-
You declare properties for
meat,saucesandtoppings, which exactly match the inputs forHamburger. Unlike aHamburger, you declare these usingvarto be able to change them. You also specifyprivate(set)for each to ensure onlyHamburgerBuildercan set them directly. -
Since you declared each property using
private(set), you need to providepublicmethods to change them. You do so viaaddSauces(_:),removeSauces(_:),addToppings(_:),removeToppings(_:)andsetMeat(_:). -
Lastly, you define
build()to create theHamburgerfrom the selections.
private(set) forces consumers to use the public setter methods. This allows the builder to perform validation before setting the properties.
For example, you’ll ensure a meat is available prior to setting it.
Add the following property right after the others:
private var soldOutMeats: [Meat] = [.kitten]
If a meat is sold out, you’ll throw an error whenever setMeat(_:) is called. You’ll need to declare a custom error type for this. Add the following code right after the opening curly brace for HamburgerBuilder:
public enum Error: Swift.Error {
case soldOut
}
Finally, replace setMeat(_:) with the following:
public func setMeat(_ meat: Meat) throws {
guard isAvailable(meat) else { throw Error.soldOut }
self.meat = meat
}
public func isAvailable(_ meat: Meat) -> Bool {
return !soldOutMeats.contains(meat)
}
If you now attempt to set kitten for the meat, you will receive an error that it’s soldOut. It’s really popular, after all!
Next, you need to declare the director. Add the following at the end of the playground:
// MARK: - Director
public class Employee {
public func createCombo1() throws -> Hamburger {
let builder = HamburgerBuilder()
try builder.setMeat(.beef)
builder.addSauces(.secret)
builder.addToppings([.lettuce, .tomatoes, .pickles])
return builder.build()
}
public func createKittenSpecial() throws -> Hamburger {
let builder = HamburgerBuilder()
try builder.setMeat(.kitten)
builder.addSauces(.mustard)
builder.addToppings([.lettuce, .tomatoes])
return builder.build()
}
}
An Employee knows how to create two burgers: createCombo1 and createKittenSpecial. It’s best to keep it simple, right? You’re finally ready to see this code in action! Add the following at the end of the playground:
// MARK: - Example
let burgerFlipper = Employee()
if let combo1 = try? burgerFlipper.createCombo1() {
print("Nom nom " + combo1.description)
}
Here, you create an instance of Employee called burgerFlipper and request combo1 be created. You should see this printed to the console:
Nom nom beef burger
Next, add the following at the end of the playground:
if let kittenBurger = try?
burgerFlipper.createKittenSpecial() {
print("Nom nom nom " + kittenBurger.description)
} else {
print("Sorry, no kitten burgers here... :[")
}
Here, you request a kitten-special burger. Since kitten is sold out, you’ll see this printed to the console:
Sorry, no kitten burgers here... :[
Aww man, you’re going to have to go somewhere else to satisfy your kitten burger cravings!
What should you be careful about?
The builder pattern works best for creating complex products that require multiple inputs using a series of steps. If your product doesn’t have several inputs or can’t be created step by step, the builder pattern may be more trouble than it’s worth.
Instead, consider providing convenience initializers to create the product.
Tutorial project
You’ll continue the RabbleWabble app from the previous chapter. Specifically, you’ll add the capability to create a new QuestionGroup using the builder pattern.
If you skipped the previous chapter, or you want a fresh start, open Finder and navigate to where you downloaded the resources for this chapter. Then, open starter\RabbleWabble\RabbleWabble.xcodeproj in Xcode. You should then skip to Implementing the builder pattern, as the starter project already has all the files you need within it.
If you instead choose to continue building your project from the last chapter, you’ll need to add a few files. The contents of these files aren’t significant to understand the builder pattern. Rather, they provide a simple starting point, so you won’t need to do tedious view setup.
Open Finder and navigate to where you have the projects downloaded for this chapter. Alongside the Starter and Final directories, you’ll see a Resources directory that contains Controllers and Views subdirectories.
Position the Finder window above Xcode and drag and drop Controllers\CreateQuestionGroupViewController.swift into the app’s Controllers group like this:
When prompted, check the option for Copy items if needed and press Finish to add the file.
Likewise, drag and drop all of the files from resources\Views into the app’s Views. Then, right-click on Views and select Sort by Name. Afterwards, your File hierarchy should look like this:
CreateQuestionGroupViewController provides the capability to create a new QuestionGroup. However, it’s not currently possible to get to this within the app.
To fix this, open Main.storyboard and pan to the Select Question Group scene. Then, press the Object library button, select the Show the Objects Library tab, enter bar button into the search field. Then, drag and drop a new bar button item as the right bar button for the Select Question Group scene.
Select the newly added bar button item, go to the Attributes Inspector and set System Item as Add.
Next, press the Object library button, enter storyboard into the search field, and drag and drop a new storyboard reference above the Question View Controller scene.
Select this storyboard reference, go to Attributes Inspector and set Storyboard as NewQuestionGroup.
Finally, Control-drag from the + bar button to the NewQuestionGroup storyboard reference. In the new window that appears, select Present Modally. This creates a segue to the NewQuestionGroup storyboard’s initial view controller.
Open NewQuestionGroup.storyboard, and you’ll see its initial view controller is set to a UINavigationController, which has CreateQuestionGroupViewController set as its root view controller.
Build and run and press + to see it in action!
If you press Cancel, however, nothing happens! What’s up with that?
CreateQuestionGroupViewController calls a delegate method whenever its cancel button is pressed. However, you haven’t hooked up the delegate yet.
To fix this, open SelectQuestionGroupViewController.swift and add the following extension at the end of the file:
// MARK: - CreateQuestionGroupViewControllerDelegate
extension SelectQuestionGroupViewController: CreateQuestionGroupViewControllerDelegate {
public func createQuestionGroupViewControllerDidCancel(
_ viewController: CreateQuestionGroupViewController) {
dismiss(animated: true, completion: nil)
}
public func createQuestionGroupViewController(
_ viewController: CreateQuestionGroupViewController,
created questionGroup: QuestionGroup) {
questionGroupCaretaker.questionGroups.append(questionGroup)
try? questionGroupCaretaker.save()
dismiss(animated: true, completion: nil)
tableView.reloadData()
}
}
This makes SelectQuestionGroupViewController conform to CreateQuestionGroupViewControllerDelegate.
This protocol requires two methods: createQuestionGroupViewControllerDidCancel(_:) is called whenever the cancel button is pressed, and createQuestionGroupViewController(_:created:) is called whenever a new QuestionGroup is created.
To handle cancellation, you simply dismiss the view controller. To handle creation, you append the new QuestionGroup to the questionGroupCaretaker.questionGroups, request it to save(), dismiss the view controller and refresh the table view.
You also need to actually set the delegate property when the segue to CreateQuestionGroupViewController is triggered. Replace prepare(for segue:sender:) with the following:
public override func prepare(
for segue: UIStoryboardSegue, sender: Any?) {
// 1
if let viewController =
segue.destination as? QuestionViewController {
viewController.questionStrategy =
appSettings.questionStrategy(for: questionGroupCaretaker)
viewController.delegate = self
// 2
} else if let navController =
segue.destination as? UINavigationController,
let viewController =
navController.topViewController as? CreateQuestionGroupViewController {
viewController.delegate = self
}
// 3
// Whatevs... skip anything else
}
Here’s what this does:
-
There’s another segue that is possible, which shows the
QuestionViewController. Previously, this was the only code within this method. You check if this is the case, and if so, set the properties onQuestionViewControllercorrectly. -
You then check if the segue is transitioning to a
CreateQuestionGroupViewControllerwithin aUINavigationController. If so, you set thedelegateon the newCreateQuestionGroupViewControllerinstance. -
If neither
ifstatement matches, you simply ignore the segue.
Build and run, tap + and then tap Cancel. The view controller will now be dismissed correctly.
If you press Save, though, nothing happens! This is because you haven’t added code to actually create a QuestionGroup yet. You need to use the builder pattern to do this.
Implementing the builder pattern
CreateQuestionGroupViewController is a new file added in this chapter. It uses a table view to accept inputs for creating a QuestionGroup. It displays CreateQuestionGroupTitleCell and CreateQuestionCell to collect input from the user.
Thereby, CreateQuestionGroupViewController is the director, and QuestionGroup is the product. Your job will be to first create a builder and then modify CreateQuestionGroupViewController to use it.
To start, right-click on the yellow RabbleWabble group and select New Group. Enter Builders for its name and move it below the AppDelegate group. This makes it clear to other developers that you’re using the builder pattern.
Right-click on your newly-added Builders group, select New File. Then choose iOS ▸ Swift File and click Next. Then enter QuestionGroupBuilder.swift for its name and press Create to add the new file.
QuestionGroupBuilder will be responsible for creating new QuestionGroups. However, QuestionGroup also contains complex child objects, Question.
What can you use to create these complex child object? Another builder, of course! You’ll create this builder first. Replace the contents of QuestionGroupBuilder.swift with the following:
public class QuestionBuilder {
public var answer = ""
public var hint = ""
public var prompt = ""
public func build() throws -> Question {
guard answer.count > 0 else { throw Error.missingAnswer }
guard prompt.count > 0 else { throw Error.missingPrompt }
return Question(answer: answer, hint: hint, prompt: prompt)
}
public enum Error: String, Swift.Error {
case missingAnswer
case missingPrompt
}
}
QuestionBuilder has properties for all of the inputs needed to create a Question: answer, hint and prompt. Initially, each of these is set to an empty string. Whenever you call build(), it validates that answer and prompt have been set. If either aren’t set, it throws a custom error; hint is optional within the app, so it’s okay if its empty. Otherwise, it returns a new Question.
You can now create QuestionGroupBuilder, which will use QuestionBuilder internally. Add the following code right before QuestionBuilder:
public class QuestionGroupBuilder {
// 1
public var questions = [QuestionBuilder()]
public var title = ""
// 2
public func addNewQuestion() {
let question = QuestionBuilder()
questions.append(question)
}
public func removeQuestion(at index: Int) {
questions.remove(at: index)
}
// 3
public func build() throws -> QuestionGroup {
guard self.title.count > 0 else {
throw Error.missingTitle
}
guard self.questions.count > 0 else {
throw Error.missingQuestions
}
let questions = try self.questions.map { try $0.build() }
return QuestionGroup(questions: questions, title: title)
}
public enum Error: String, Swift.Error {
case missingTitle
case missingQuestions
}
}
Here’s what’s going on:
-
You first declare properties matching the required inputs to create a
QuestionGroup. You create an array ofQuestionBuilders, which will build the individualquestionobjects. You initially create a singleQuestionBuilderso that there is one to start with. A question group must have at least one question after all! -
As its name implies, you’ll use
addNewQuestion()to create and append a newQuestionBuilderontoquestions. Similarly,removeQuestion(at:)will remove aQuestionBuilderby index fromquestions. -
Whenever you call
build(), theQuestionBuildervalidates thattitlehas been set and there’s at least oneQuestionBuilderwithinquestions. If not, it throws an error. If both conditions pass, it attempts to createQuestionsby callingbuild()on eachQuestionBuilder. This too can fail and result in an error thrown by an invalidQuestion. If everything goes well, it returns a newQuestionGroup.
You’re now ready to use QuestionBuilder! Open CreateQuestionGroupViewController.swift, and you’ll see there are several // TODO comments. Each of these requires you to use QuestionBuilder to complete them.
First, add this property right after delegate:
public let questionGroupBuilder = QuestionGroupBuilder()
Since you set all of the properties of QuestionGroupBuilder to default values, you don’t have to pass anything to create a QuestionGroupBuilder. Nice and easy!
Next, replace the return statement within tableView(_:numberOfRowsInSection:) with this:
return questionGroupBuilder.questions.count + 2
CreateQuestionGroupViewController displays three types of table view cells: one for the title of the QuestionGroup, one for each QuestionBuilder and one to add additional QuestionBuilder objects. Hence, this results in questionGroupBuilder.questions.count + 2 for the total number of cells.
Within tableView(_:cellForRowAt:), replace this line:
} else if row == 1 {
with this instead:
} else if row >= 1 &&
row <= questionGroupBuilder.questions.count {
The previous code assumed there was only one QuestionBuilder cell. Here, you update this to take into account that there could be several.
Replace the // TODO: within titleCell(from:for:) with the following:
cell.titleTextField.text = questionGroupBuilder.title
Here, you simply display the text from questionGroupBuilder.title.
Next, add the following after questionCell(from:for:):
private func questionBuilder(
for indexPath: IndexPath) -> QuestionBuilder {
return questionGroupBuilder.questions[indexPath.row - 1]
}
This is a helper method to get the QuestionBuilder for a given index path. You’ll need this a few times hereafter, so it’s beneficial to define this in only one place.
Replace the // TODO: within questionCell(from:for:) with the following:
let questionBuilder = self.questionBuilder(for: indexPath)
cell.delegate = self
cell.answerTextField.text = questionBuilder.answer
cell.hintTextField.text = questionBuilder.hint
cell.indexLabel.text = "Question \(indexPath.row)"
cell.promptTextField.text = questionBuilder.prompt
This configures the given CreateQuestionCell using values from the QuestionBuilder at the given indexPath.
Replace // TODO: - Add UITableViewDelegate methods with the following:
public override func tableView(
_ tableView: UITableView,
didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard isLastIndexPath(indexPath) else { return }
questionGroupBuilder.addNewQuestion()
tableView.insertRows(at: [indexPath], with: .top)
}
private func isLastIndexPath(_ indexPath: IndexPath) -> Bool {
return indexPath.row ==
tableView.numberOfRows(inSection: indexPath.section) - 1
}
Whenever a table view cell is tapped, tableView(_:didSelectRowAt:) checks if the indexPath matches isLastIndexPath. If it does, then the user has clicked the “Add” cell at the bottom of the table view. In this case, you request questionGroupBuilder.addNewQuestion() and insert a new cell to show the new QuestionBuilder.
Build and run. Then tap + to navigate to CreateQuestionGroupViewController to try out the changes.
You can now add additional QuestionBuilder instances and cells to the table view. Awesome!
If you input text for several questions and create many new cells thereafter, you’ll notice that your text is gone after scrolling the table view. This is because you haven’t actually persisted the text input into the cells onto each QuestionBuilder.
Fortunately, CreateQuestionGroupViewController already conforms to CreateQuestionCellDelegate, which is called by CreateQuestionCell whenever answer, hint and prompt text changes. So you just need to complete these methods!
Add the following right after createQuestionCell(_:promptTextDidChange:):
private func questionBuilder(
for cell: CreateQuestionCell) -> QuestionBuilder {
let indexPath = tableView.indexPath(for: cell)!
return questionBuilder(for: indexPath)
}
You’ll use this helper to determine the QuestionBuilder for a given cell, which you do so by finding the cell’s indexPath and then using the helper method you wrote earlier for questionBuilder(for indexPath:).
Replace the // TODO: within createQuestionCell(_:answerTextDidChange:) with the following:
questionBuilder(for: cell).answer = text
This sets the answer on the QuestionBuilder for the given cell.
Likewise, replace the // TODO: within createQuestionCell(_:hintTextDidChange:) with this:
questionBuilder(for: cell).hint = text
Then, replace the the // TODO: within createQuestionCell(_:promptTextDidChange:) with this:
questionBuilder(for: cell).prompt = text
These set the hint and prompt on the QuestionBuilder for the given cell.
While you’re at it, you also need to complete createQuestionGroupTitleCell(_:titleTextDidChange:) to persist the title for the QuestionGroup. Replace the // TODO inside that with the following:
questionGroupBuilder.title = text
Build and run, navigate to CreateQuestionGroupViewController and again enter several texts’ worth of questions and try scrolling around. This time, everything should work as expected!
However, the Save button still doesn’t do anything. It’s time for you to fix this. Replace savePressed(_:) with the following:
@IBAction func savePressed(_ sender: Any) {
do {
let questionGroup = try questionGroupBuilder.build()
delegate?.createQuestionGroupViewController(
self, created: questionGroup)
} catch {
displayMissingInputsAlert()
}
}
public func displayMissingInputsAlert() {
let alert = UIAlertController(
title: "Missing Inputs",
message: "Please provide all non-optional values",
preferredStyle: .alert)
let okAction = UIAlertAction(title: "Ok",
style: .default,
handler: nil)
alert.addAction(okAction)
present(alert, animated: true, completion: nil)
}
You attempt to create a new QuestionGroup by calling questionGroupBuilder.build(). If this succeeds, you notify the delegate.
If it throws an error, you alert the user to input all required fields. Build and run, navigate to CreateQuestionGroupViewController, enter a title, and create a couple of questions.
Tap Save, and you’ll then see your brand-new QuestionGroup added to the Select Question Group listing!
Key points
You learned the builder pattern in this chapter. Here are its key points:
-
The builder pattern is great for creating complex objects in a step-by-step fashion. It involves three objects: the director, product and builder.
-
The director accepts inputs and coordinates with the builder; the product is the complex object that’s created; and the builder takes step-by-step inputs and creates the product.
Where to go from here?
RabbleWabble has really come a long way since you created it, but there’s still a lot of functionality you can add.
- Editing and deleting
QuestionGroups. - Tracking and showing scores over time.
- Showing questions using a spaced repetition algorithm.
Each of these are possible using the existing patterns you learned in this “Fundamental Design Patterns” section. Feel free to continue building out Rabble Wabble as much as you like.
If you’ve worked through this entire first section, congratulations are in order: You’ve learned many of the most commonly used iOS design patterns!
But your design patterns journey doesn’t stop here. Continue onto the next section to learn about intermediate design patterns, including MVVM, Adapter, Factory and more!