Building Authentication Systems in Swift with the Builder Design Pattern

Leveraging Design Patterns for Scalable Authentication Systems

·

4 min read

The Builder Design Pattern is a creational pattern.

  1. It construct complex objects step by step.

  2. It separates the construction of a complex object from its representation.

  3. 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:

  1. The Product, which represents the complex object being built.

  2. The Builder, an abstract interface or base class defining the steps for constructing the product.

  3. 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:

  1. It constructs the complex product AuthRequest by following specific steps.

  2. It separates the representation of the object through public methods like setEmail, setPassword, and setToken.

  3. 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.