Building Authentication Systems in Swift with the Builder Design Pattern
Leveraging Design Patterns for Scalable Authentication Systems
Photo by Aliata Karbaschi on Unsplash
The Builder Design Pattern is a creational pattern.
It construct complex objects step by step.
It separates the construction of a complex object from its representation.
It allows to create different types and representations of an object using the same construction code.
The Builder pattern typically involves two or three key components:
The Product, which represents the complex object being built.
The Builder, an abstract interface or base class defining the steps for constructing the product.
An optional Director, which orchestrates the construction process and interacts with clients to encapsulate the complexity further.
What Problem Does the Builder Design Pattern Solve?
The Builder pattern addresses the need to construct objects or products that require a specific process. It is especially useful when creating an object with multiple configurations or options.
In our example, the goal is to design an Authentication System capable of building an authentication request for various configurations, such as:
Email/Password authentication
OAuth token authentication
Custom credential-based authentication
class AuthRequest {
var email: String?
var password: String?
var token: String?
var metadata: [String: String] = [:]
func description() -> String {
var desc = "AuthRequest("
if let email = email { desc += "email: \(email), " }
if let password = password { desc += "password: \(password), " }
if let token = token { desc += "token: \(token), " }
desc += "metadata: \(metadata)"
desc += ")"
return desc
}
}
The Builder is an abstract interface adopted by concrete types to define how the product (in this case, AuthRequest
) is constructed.
protocol AuthRequestBuilder {
private var authRequest: AuthRequest { get }
func setEmail(_ email: String) -> Self
func setPassword(_ password: String) -> Self
func setToken(_ token: String) -> Self
func setMetadata(_ key: String, value: String) -> Self
func build() -> AuthRequest
}
A key point is that the AuthRequest
instance is kept private within the builder, ensuring that it cannot be accessed or modified until the build
method is called. This guarantees that the object is fully constructed before being used.
class ConcreteAuthRequestBuilder: AuthRequestBuilder {
private var authRequest = AuthRequest()
func setEmail(_ email: String) -> Self {
authRequest.email = email
return self
}
func setPassword(_ password: String) -> Self {
authRequest.password = password
return self
}
func setToken(_ token: String) -> Self {
authRequest.token = token
return self
}
func setMetadata(_ key: String, value: String) -> Self {
authRequest.metadata[key] = value
return self
}
func build() -> AuthRequest {
return authRequest
}
}
The Director acts as a helper for the client, encapsulating the complexity of constructing different authentication systems. It provides a high-level API to build products, such as “email/password authentication” or “OAuth token authentication,” without exposing the details of the builder's implementation.
class AuthDirector {
private let builder: AuthRequestBuilder
init(builder: AuthRequestBuilder) {
self.builder = builder
}
func buildEmailAuth(email: String, password: String) -> AuthRequest {
return builder
.setEmail(email)
.setPassword(password)
.build()
}
func buildOAuth(token: String) -> AuthRequest {
return builder
.setToken(token)
.build()
}
func buildCustomAuth(email: String, metadata: [String: String]) -> AuthRequest {
var builder = self.builder.setEmail(email)
for (key, value) in metadata {
builder = builder.setMetadata(key, value: value)
}
return builder.build()
}
}
While using a Director can make the code more concise and structured, it is not mandatory. Clients can interact directly with the Builder if desired. The Director simply acts as a convenience layer, or what some might call “syntactic sugar.”
func main() {
let builder = ConcreteAuthRequestBuilder()
let director = AuthDirector(builder: builder)
// Example 1: Email & Password Authentication
let emailAuth = director.buildEmailAuth(email: "user@example.com", password: "password123")
print(emailAuth.description())
// Output: AuthRequest(email: user@example.com, password: password123, metadata: [:])
// Example 2: OAuth Authentication
let oauthAuth = director.buildOAuth(token: "oauth-token-123")
print(oauthAuth.description())
// Output: AuthRequest(token: oauth-token-123, metadata: [:])
// Example 3: Custom Authentication with Metadata
let customAuth = director.buildCustomAuth(email: "custom@example.com", metadata: ["device": "iPhone", "app_version": "1.0.0"])
print(customAuth.description())
// Output: AuthRequest(email: custom@example.com, metadata: ["device": "iPhone", "app_version": "1.0.0"])
}
Now recall the above bullets points & ask Why is the Builder Pattern Useful?
Consider the ConcreteAuthRequestBuilder
from our implementation:
It constructs the complex product
AuthRequest
by following specific steps.It separates the representation of the object through public methods like
setEmail
,setPassword
, andsetToken
.By combining these steps, it can create multiple types of authentication requests, such as:
An email/password-based authentication.
An OAuth token-based authentication.
A custom configuration-based authentication.
This clear separation of concerns makes the construction process modular and flexible.
A Word of Caution
In my opinion, the Builder pattern emphasizes the process of creating objects rather than the object itself. While this adds flexibility, it can introduce potential pitfalls:
There’s no enforcement to ensure that all necessary steps are followed.
The sequence of steps may not be maintained, leading to inconsistencies.
If a developer forgets to call the
build
method or skips critical steps, the resulting object might be incomplete or invalid.
For this reason, care must be taken when implementing the pattern, especially in projects with multiple developers. Proper documentation and code reviews can mitigate these risks.