The builder pattern isn't a common pattern in Swift and Cocoa development and you don't find it in any of Apple's frameworks. It is one of the Gang of Four design patterns and widely used in Java development.
As the name suggests, the builder pattern is aimed at object creation and configuration. The idea is simple. You pass the requirements of the object you want to create to a builder. The builder uses those requirements to create and configure the object. You could see the builder as a factory that is capable of creating a range of variations or representations of one or more types.
Building URLs
In this episode, I show you how to use the builder pattern to create URLs. Services such as Cloudinary make it easy to fetch and manipulate remote images. Cloudinary's fetch API is easy to use and makes it trivial to create the image you need. Let's explore Cloudinary's fetch API before we implement the builder pattern.
We start with a Cloudinary base URL that is specific to your account.
https://res.cloudinary.com/cocoacasts/image/fetch/
You append the URL of the remote image you want to download. When you make the request, Cloudinary downloads the image and returns it. It also caches the image. The next time that image is accessed through Cloudinary, the cached image is returned. In other words, Cloudinary also acts as a content delivery network or CDN.
https://res.cloudinary.com/cocoacasts/image/fetch/https://cocoacasts.com/exmple.svg
But Cloudinary is more than a CDN. You can pass additional parameters to Cloudinary to manipulate and transform the image. In this example, the SVG image should be converted to a PNG image. This is as simple as passing a parameter to the Cloudinary fetch API.
https://res.cloudinary.com/cocoacasts/image/fetch/f_png/https://cocoacasts.com/exmple.svg
You can also pass Cloudinary's fetch API the pixel density of the device. A device with a retina display has a pixel density of 2 or 3. The resolution of the image returned by Cloudinary is modified for the pixel density of the device.
https://res.cloudinary.com/cocoacasts/image/fetch/f_png,dpr_2.0/https://cocoacasts.com/exmple.svg
We can also constrain the dimensions of the returned image. Scrolling a table or collection view needs to be snappy and that is only possible if the displayed images are sized appropriately. Cloudinary makes that trivial. In this example, we ask Cloudinary to return an image that is 200.0 points wide. On a device with a pixel density of 2 the returned image is 400.0 pixels wide.
https://res.cloudinary.com/cocoacasts/image/fetch/w_200,f_png,dpr_2.0/https://cocoacasts.com/exmple.svg
How to Implement the Builder Pattern
Fire up Xcode and create a playground by choosing the Blank template from the iOS > Playground section.
Remove the contents of the playground with the exception of the import statement for UIKit. We define the URL of the remote image we want to fetch using Cloudinary's fetch API.
import UIKit
let imageUrl = URL(string: "https://cdn.cocoacasts.com/assets/cocoacasts.svg")!
The next step is defining the Cloudinary base URL.
import UIKit
let imageUrl = URL(string: "https://cdn.cocoacasts.com/assets/cocoacasts.svg")!
let url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
The remote image we want to fetch is an SVG (Scalable Vector Graphics) image. Even though asset catalogs support the SVG format, the UIKit framework doesn't have built-in support for SVG images.
We can use Cloudinary to work around this limitation. You can pass a parameter to the fetch API to convert an image from one format to another. Let's convert the SVG image to a PNG image by appending the f_png
parameter to the base URL.
import UIKit
let imageUrl = URL(string: "https://cdn.cocoacasts.com/assets/cocoacasts.svg")!
let url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
.appendingPathComponent("f_png")
We convert the URL of the remote image to a string and append it to the Cloudinary URL.
import UIKit
let imageUrl = URL(string: "https://cdn.cocoacasts.com/assets/cocoacasts.svg")!
let url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
.appendingPathComponent("f_png")
.appendingPathComponent(imageUrl.absoluteString)
We can also define the size of the image by appending width
and height
parameters. In this example, we set the width to 200.0 points.
import UIKit
let imageUrl = URL(string: "https://cdn.cocoacasts.com/assets/cocoacasts.svg")!
let url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
.appendingPathComponent("f_png")
.appendingPathComponent(imageUrl.absoluteString)
.appendingPathComponent("w_\(200)")
We can set the device pixel ratio through the dpr
parameter.
import UIKit
let imageUrl = URL(string: "https://cdn.cocoacasts.com/assets/cocoacasts.svg")!
let url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
.appendingPathComponent("f_png")
.appendingPathComponent(imageUrl.absoluteString)
.appendingPathComponent("w_\(200)")
.appendingPathComponent("dpr_2")
Creating a Builder
Cloudinary's API is easy to use, but you don't want to create the URLs for remote images like this. This solution doesn't scale and it isn't easy to unit test. It also makes moving to a different provider a pain.
Let's use the builder pattern to encapsulate the creation of the URLs for remote images and remove the need for string literals at the call site. The primary goal of the solution we are about to implement is adding flexibility and reusability.
We need to create an object that helps construct or build the Cloudinary URL. We first define the builder, a final class with name ImageURLBuilder
. The class defines a private, constant property, source
, of type URL
. The value of source is the URL of the remote image we send to Cloudinary.
final class ImageURLBuilder {
// MARK: - Properties
private let source: URL
}
Before we implement the API, we define an initializer that accepts one argument, source
, of type URL
. We set the source
property in the body of the initializer.
final class ImageURLBuilder {
// MARK: - Properties
private let source: URL
// MARK: - Initialization
init(source: URL) {
// Set Properties
self.source = source
}
}
The ImageURLBuilder
class builds the Cloudinary URL. We start with a basic API. I would like to add the ability to specify the width and/or height of the returned image. We define a private, variable property, width
, of type Int?
and a private, variable property, height
, of type Int?
. The ImageURLBuilder
class should not expose any state. The URL is configured through the public API of the ImageURLBuilder
class. That is why width
and height
are declared privately.
final class ImageURLBuilder {
// MARK: - Properties
private let source: URL
// MARK: -
private var width: Int?
private var height: Int?
// MARK: - Initialization
init(source: URL) {
// Set URL
self.source = source
}
}
Let's implement the public API. We define a method with name width(_:)
that accepts an integer as its only argument. Notice that it returns an ImageURLBuilder
instance. Why that is becomes clear in a moment. The implementation of the width(_:)
method is simple. The width
property is set and self
is returned from the method.
func width(_ width: Int) -> ImageURLBuilder {
// Update Width
self.width = width
return self
}
We repeat these steps for the height
property. We define a method with name height(_:)
that accepts an integer as its only argument. The height
property is set and self
is returned from the method.
func height(_ height: Int) -> ImageURLBuilder {
// Update Height
self.height = height
return self
}
Once the ImageURLBuilder
instance is configured, the build()
method is invoked to create or build the object, the Cloudinary URL in this example. We could define build()
as a computed property. By defining it as a method, it is clear that the ImageURLBuilder
instance performs an action. We start by declaring a few helper variables, parameters
, an empty array of String
objects, and url
, the Cloudinary base URL.
func build() -> URL {
// Helpers
var parameters: [String] = []
var url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
}
We safely unwrap the width
property. If width
isn't equal to nil
, we append it as a parameter to the parameters array. The format of the parameter is w_\(width)
. We repeat this step for the height
property. If height
isn't equal to nil
, we append it as a parameter to the parameters array. The format of the parameter is h_\(height)
.
func build() -> URL {
// Helpers
var parameters: [String] = []
var url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
if let width = width {
parameters.append("w_\(width)")
}
if let height = height {
parameters.append("h_\(height)")
}
}
We also append the format parameter to the array of parameters.
func build() -> URL {
// Helpers
var parameters: [String] = []
var url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
if let width = width {
parameters.append("w_\(width)")
}
if let height = height {
parameters.append("h_\(height)")
}
// Define Format
parameters.append("f_png")
}
The last parameter we add is the device pixel density parameter. We ask the main screen for the value of its scale
property, convert it to a string, and append it to the array of parameters.
func build() -> URL {
// Helpers
var parameters: [String] = []
var url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
if let width = width {
parameters.append("w_\(width)")
}
if let height = height {
parameters.append("h_\(height)")
}
// Define Format
parameters.append("f_png")
// Define Device Pixel Ratio
let dpr = String(format: "%1.1f", UIScreen.main.scale)
parameters.append("dpr_\(dpr)")
}
We concatenate the array of strings if parameters
isn't empty, using a comma to separate the strings. The resulting string is appended to the Cloudinary base URL.
func build() -> URL {
// Helpers
var parameters: [String] = []
var url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
if let width = width {
parameters.append("w_\(width)")
}
if let height = height {
parameters.append("h_\(height)")
}
// Define Format
parameters.append("f_png")
// Define Device Pixel Ratio
let dpr = String(format: "%1.1f", UIScreen.main.scale)
parameters.append("dpr_\(dpr)")
// Append Parameters
if !parameters.isEmpty {
let parametersAsString = parameters.joined(separator: ",")
url = url.appendingPathComponent(parametersAsString)
}
}
The last step is appending the source URL as a string to the Cloudinary URL and returning it from the build()
method.
func build() -> URL {
// Helpers
var parameters: [String] = []
var url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
if let width = width {
parameters.append("w_\(width)")
}
if let height = height {
parameters.append("h_\(height)")
}
// Define Format
parameters.append("f_png")
// Define Device Pixel Ratio
let dpr = String(format: "%1.1f", UIScreen.main.scale)
parameters.append("dpr_\(dpr)")
// Append Parameters
if !parameters.isEmpty {
let parametersAsString = parameters.joined(separator: ",")
url = url.appendingPathComponent(parametersAsString)
}
return url.appendingPathComponent(source.absoluteString)
}
Let's use the ImageURLBuilder
class to create the URL we created at the start of this episode. We first instantiate an instance of the ImageURLBuilder
class, passing in the source URL of the remote image. We invoke the width(_:) method on the builder to define the width of the returned image. To build the Cloudinary URL, we invoke the build()
method. The results panel on the right shows the resulting URL.
ImageURLBuilder(source: imageUrl)
.width(200)
.build()
We can use method chaining because the width(_:)
and height(_:)
methods return self
, the ImageURLBuilder
instance. That is why those methods return self
.
The API is clean, elegant, and easy to work with. We could take it a step further and eliminate the number literal we pass to the width(_:)
method. This is as simple as defining a nested enum that maps to an integer. This subtle change increases the readability of the API and we no longer need to use number literals at the call site.
final class ImageURLBuilder {
// MARK: - Types
enum Size: Int {
case small = 100
case medium = 200
case large = 400
}
// MARK: - Properties
private let source: URL
// MARK: -
private var width: Size?
private var height: Size?
// MARK: - Initialization
init(source: URL) {
// Set URL
self.source = source
}
// MARK: - Public API
func width(_ width: Size) -> ImageURLBuilder {
// Update Width
self.width = width
return self
}
func height(_ height: Size) -> ImageURLBuilder {
// Update Height
self.height = height
return self
}
func build() -> URL {
// Helpers
var parameters: [String] = []
var url = URL(string: "https://res.cloudinary.com/cocoacasts/image/fetch/")!
if let width = width {
parameters.append("w_\(width.rawValue)")
}
if let height = height {
parameters.append("h_\(height.rawValue)")
}
// Define Format
parameters.append("f_png")
// Define Device Pixel Ratio
let dpr = String(format: "%1.1f", UIScreen.main.scale)
parameters.append("dpr_\(dpr)")
// Append Parameters
if !parameters.isEmpty {
let parametersAsString = parameters.joined(separator: ",")
url = url.appendingPathComponent(parametersAsString)
}
return url.appendingPathComponent(source.absoluteString)
}
}
ImageURLBuilder(source: imageUrl)
.width(.medium)
.build()
Injecting Dependencies
To decouple the ImageURLBuilder
class from the UIKit framework and improve its testability, I recommend injecting the scale factor of the screen. We define a property for the scale factor, scale
, of type CGFloat
.
private let scale: CGFloat
The initializer accepts the scale factor of the screen as an argument. To keep the API clean and concise, we default to the scale factor of the main screen. In a unit test, we would pass the scale factor to the initializer to be in control of the unit test and its outcome.
// MARK: - Initialization
init(source: URL, scale: CGFloat) {
// Set Properties
self.source = source
self.scale = scale
}
In the build()
method, we no longer need to reference UIScreen
.
func build() -> URL {
...
// Define Device Pixel Ratio
let dpr = String(format: "%1.1f", scale)
parameters.append("dpr_\(dpr)")
...
}
Pros and Cons
I first came across the builder pattern in one of Google's SDKs several years ago. I have to admit that I wasn't immediately convinced of the benefits of the pattern. There are several ways to adopt the builder pattern and if you don't get it right it feels verbose and isn't pleasant to use.
The most important benefit of the builder pattern is that the creation and configuration of the object are encapsulated by the builder. This is useful to control how the object is used and configured in the project. Because the builder is responsible for the creation and configuration of the object, you can keep tight control over both aspects.
Encapsulation has another nice benefit. The ImageURLBuilder
class doesn't expose the Cloudinary fetch API. If we ever want to switch to a different provider, we only need to modify the ImageURLBuilder
class. That is a major benefit in my book.
Another benefit is that builders get rid of lengthy initializers and they promote immutability. The builder encapsulates the information it needs to create the object and exposes an elegant and flexible API.
The builder pattern also makes unit testing much easier. You define the input for the builder and verify that the output, the object the builder creates, is configured the way you expect it to be.
The most important downside is that you need two objects to create one object. In other words, if you use the builder pattern extensively in a project, you can end up with many types and files. That is a price I am willing to pay for these benefits.
What's Next?
Even though I use the builder pattern sparingly in the projects I work on, I very much appreciate the benefits builders bring to a codebase. Use the pattern when it feels appropriate. Not every object should be created by a builder.