Which design patterns have you used in iOS, and what tradeoffs did they have?

Group design patterns into creational, structural, and behavioral, then explain real iOS examples from SDKs and your own implementation experience with the tradeoffs that matter in production

Answer

The core idea

Design patterns are typical solutions to commonly occurring problems in software design. They are like pre-made blueprints that you can customize to solve a recurring design problem in your code.

Design patterns are usually grouped into three categories:

  • Creational patterns control object creation to improve flexibility and reuse.
  • Structural patterns assemble objects and classes into larger flexible structures.
  • Behavioral patterns define how algorithms and responsibilities are shared between objects.

1. Creational patterns

Creational patterns answer where objects come from. They are useful when construction logic is repeated, depends on environment, or needs to be swapped in tests.

Factory Method

Factory Method is a creational design pattern that defines an interface for creating an object, but lets subclasses or creator methods decide which concrete type, subclass, or configuration to return.

Why use it: Factory Method avoids tight coupling between code that *uses* an object and code that *creates* the concrete object. It keeps construction logic in one place, makes tests easier to swap, and lets you introduce a new product type without rewriting every caller.

In Swift, a factory is often an enum, static func, or module builder that hides the concrete type or wiring details from the caller.

swift 5.2
// Enum-based factory: the caller chooses the case, not the concrete type.
enum PaymentMethod {
    case card
    case applePay

    func makePayment() -> Payment {
        switch self {
        case .card:
            return CardPayment()
        case .applePay:
            return ApplePayPayment()
        }
    }
}

The same idea works for configured Foundation objects:

swift 5.2
extension DateFormatter {
    static func makeISO8601() -> DateFormatter {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
        return formatter
    }
}

VIPER modules often use a builder as a factory for an entire object graph:

swift 5.2
final class ProfileModuleBuilder {
    static func build(userID: String) -> UIViewController {
        let view = ProfileViewController()
        let presenter = ProfilePresenter()
        let interactor = ProfileInteractor()
        let router = ProfileRouter()

        view.presenter = presenter

        presenter.view = view
        presenter.interactor = interactor
        presenter.router = router
        presenter.userID = userID

        interactor.presenter = presenter
        router.viewController = view

        return view
    }
}

The key idea is that the factory method encapsulates the logic for creating an instance and can return a parent class or protocol type, so callers depend on the abstraction instead of the concrete implementation details.

Builder

Builder is a creational design pattern that lets you construct complex objects step by step. The pattern allows you to produce different types and representations of an object using the same construction code.

Why use it: Builder avoids a telescoping constructor with too many optional parameters, keeps construction rules in one place, and lets callers add optional pieces step by step.

In this example, endpointBuilder is preconfigured once with the shared API host, then a call site adds the endpoint-specific path and query pieces before calling build() to produce the final URL:

swift 5.2
import Foundation

struct EndpointBuilder {
    private let components: URLComponents

    init(scheme: String, host: String) {
        var components = URLComponents()
        components.scheme = scheme
        components.host = host
        self.components = components
    }

    private init(components: URLComponents) {
        self.components = components
    }

    func path(_ value: String) -> EndpointBuilder {
        var copy = components
        copy.path = value
        return EndpointBuilder(components: copy)
    }

    func query(_ name: String, _ value: String?) -> EndpointBuilder {
        guard let value = value else { return self }
        var copy = components
        var items = components.queryItems ?? []
        items.append(URLQueryItem(name: name, value: value))
        copy.queryItems = items
        return EndpointBuilder(components: copy)
    }

    func build() throws -> URL {
        guard let url = components.url else { throw URLError(.badURL) }
        return url
    }
}

let endpointBuilder = EndpointBuilder(scheme: "https", host: "api.example.com")

let url = try endpointBuilder
    .path("/users")
    .query("page", "1")
    .query("search", nil)
    .build()
print("URL: ", url)

Run compiles and executes on the server; output shows below.

The URL-building rules stay centralized, optional query values are skipped consistently, and call sites remain readable.

For simple values with a few required fields and no staged validation, a normal initializer is still clearer.

Singleton

Singleton is a creational pattern that ensures a type has one shared instance and gives clients a global access point to it. In Swift, the common shape is static let shared plus a private init().

Why use it: Singleton can make sense when one object owns process-wide state, wraps an external SDK, or performs expensive setup that should happen once.

An analytics tracker can expose one shared instance, while feature code still receives the tracker through a protocol so tests can inject a replacement.

swift 5.2
protocol AnalyticsTracking {
    func track(_ event: String)
}

final class AnalyticsTracker: AnalyticsTracking {
    static let shared = AnalyticsTracker()

    private init() {}

    func track(_ event: String) {
        print("Tracked:", event)
    }
}

struct CheckoutViewModel {
    let analytics: AnalyticsTracking

    func didTapPay() {
        analytics.track("pay_tapped")
    }
}

let viewModel = CheckoutViewModel(analytics: AnalyticsTracker.shared)
viewModel.didTapPay()

Run compiles and executes on the server; output shows below.

App code can reuse the same analytics instance, but CheckoutViewModel remains testable because it depends on AnalyticsTracking instead of directly reaching for .shared.

The downside is that a singleton is still shared global state. If it stores mutable data, that state must be protected in a multithreaded app, often with an actor, lock, serial queue, or another synchronization boundary. It also makes tests harder when client code reaches directly for .shared, because the test cannot easily replace or reset the dependency.

2. Structural patterns

Structural patterns answer how types fit together. They let you wrap, compose, or simplify existing APIs without spreading glue code across the app.

Adapter

Adapter converts the interface of one object into another interface that client code expects. It lets incompatible types work together without changing either side directly.

Why use it: Adapter keeps third-party or platform-specific details at the boundary of the app. Feature code talks to an app-owned protocol, while the adapter translates to the SDK's concrete API.

In iOS code, this often means wrapping analytics SDKs behind one AnalyticsTracking protocol. Mixpanel accepts string event names and Mixpanel properties, while Firebase Analytics uses FirebaseAnalytics.Analytics.logEvent(...) with its own event and parameter name types.

swift 5.2
protocol AnalyticsTracking {
    func logEvent(_ event: AnalyticsEvent)
}

final class MixpanelAnalyticsAdapter: AnalyticsTracking {
    func logEvent(_ event: AnalyticsEvent) {
        let properties = event.parameters.compactMapValues { $0 as? MixpanelType }
        Mixpanel.mainInstance().track(event: event.name, properties: properties)
    }
}

final class FirebaseAnalyticsAdapter: AnalyticsTracking {
    func logEvent(_ event: AnalyticsEvent) {
        FirebaseAnalytics.Analytics.logEvent(event.name, parameters: event.parameters)
    }
}

When an SDK changes, you update the adapter instead of editing every screen that tracks analytics.

Facade

Facade gives a simple entry point over several subsystems. It hides multiple moving parts behind a smaller API designed for the caller's use case.

Why use it: Facade reduces duplicated orchestration code. Instead of every screen knowing about cache reads, network fetches, decoding, and disk writes, one type owns that workflow.

A CatalogRepository can hide disk storage, decoding, and URLSession behind one method, so callers ask for items(forceRefresh:) instead of repeating cache-and-network logic.

swift 5.2
actor CatalogRepository {
    private let fileURL: URL
    private let session: URLSession

    init(session: URLSession = .shared, fileURL: URL) {
        self.session = session
        self.fileURL = fileURL
    }

    func items(forceRefresh: Bool) async throws -> [CatalogItem] {
        if !forceRefresh, let cached = try loadFromDisk() {
            return cached
        }
        let remote = try await fetchFromNetwork()
        try saveToDisk(remote)
        return remote
    }

    private func loadFromDisk() throws -> [CatalogItem]? {
        guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
        let data = try Data(contentsOf: fileURL)
        return try JSONDecoder().decode([CatalogItem].self, from: data)
    }

    private func saveToDisk(_ items: [CatalogItem]) throws {
        let data = try JSONEncoder().encode(items)
        try data.write(to: fileURL, options: [.atomic])
    }

    private func fetchFromNetwork() async throws -> [CatalogItem] {
        let url = URL(string: "https://example.com/catalog")!
        let (data, _) = try await session.data(from: url)
        return try JSONDecoder().decode([CatalogItem].self, from: data)
    }
}

Screens get one async method for catalog loading, while persistence and networking details stay behind the repository boundary.

Facades should stay focused. If one type owns dozens of unrelated operations, it stops simplifying the design and becomes a god object.

Decorator

Decorator adds behavior around an existing object while keeping the same interface. In Swift, this often means wrapping a protocol implementation to add logging, retry, caching, or metrics without subclassing.

Why use it: Decorator adds cross-cutting behavior without changing the original object or forcing a subclass hierarchy. Multiple decorators can be composed when each wrapper keeps the same protocol.

A logging network client can wrap any NetworkClient, print request information, and then forward the call to the real implementation.

swift 5.2
import Foundation

protocol NetworkClient: Sendable {
    func data(for request: URLRequest) async throws -> (Data, URLResponse)
}

struct LoggingNetworkClient: NetworkClient {
    let inner: NetworkClient

    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        print("→ \(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "")") // ✅ Cross-cutting concern
        return try await inner.data(for: request)
    }
}

Logging can be enabled, removed, or swapped in tests without changing the network client's core request logic.

3. Behavioral patterns

Behavioral patterns answer how work moves between objects. They cover notifications, interchangeable algorithms, callbacks, validation chains, and other collaboration rules.

Observer

Observer notifies dependents when something changes. On Apple platforms, common forms include NotificationCenter, Combine publishers, and ObservableObject with @Published state observed by SwiftUI.

Why use it: Observer keeps the subject decoupled from the objects reacting to changes. The publisher exposes state changes, and observers decide how to respond.

A settings view controller can observe a view model's theme property and update the UI whenever the value changes.

swift 5.2
import Combine

final class SettingsViewModel: ObservableObject {
    @Published var theme: String = "system"
}

final class SettingsViewController {
    private let viewModel: SettingsViewModel
    private var cancellables = Set<AnyCancellable>()

    init(viewModel: SettingsViewModel) {
        self.viewModel = viewModel

        viewModel.$theme
            .dropFirst()
            .sink { [weak self] newTheme in
                self?.apply(theme: newTheme)
            }
            .store(in: &cancellables)
    }
}

The view model does not know about the view controller, and the view controller updates only when the observed value changes.

Strategy

Strategy swaps algorithms behind one stable call site. JSONEncoder.DateEncodingStrategy is a built-in Swift example: the encoding call stays the same, but the date-formatting behavior changes.

Why use it: Strategy keeps conditional algorithm choices out of the caller. Code can select behavior by passing a strategy instead of branching throughout the feature.

The same encode(_:using:) function can encode an event with seconds-since-1970 dates for one API and ISO-8601 dates for another.

swift 5.2
import Foundation

struct Event: Encodable {
    let name: String
    let createdAt: Date
}

func encode(_ event: Event, using strategy: JSONEncoder.DateEncodingStrategy) throws -> String {
    let encoder = JSONEncoder()
    encoder.dateEncodingStrategy = strategy
    let data = try encoder.encode(event)
    return String(data: data, encoding: .utf8)!
}

let event = Event(name: "pay_tapped", createdAt: Date(timeIntervalSince1970: 0))

print(try encode(event, using: .secondsSince1970))
print(try encode(event, using: .iso8601))

Run compiles and executes on the server; output shows below.

The model and encoding call stay the same, while each API can choose the date format it expects.

Chain of Responsibility

Chain of Responsibility passes input through ordered handlers until one handles it or the chain finishes. Deeplink routing is a common iOS example.

Why use it: Chain of Responsibility avoids one large router full of unrelated if or switch branches. Each handler owns one decision and forwards anything it does not handle.

Deeplink routing can use a chain where a profile handler checks /profile, a settings handler checks /settings, and unhandled URLs continue to the next handler. UIKit’s responder chain is the classic platform example: touch events can travel from a UIView to its superview and then toward a UIViewController until something handles the event or the chain ends (see UIResponder).

swift 5.2
import Foundation

protocol DeeplinkHandler {
    var next: DeeplinkHandler? { get }
    func handle(_ url: URL) -> Bool
}

struct ProfileDeeplinkHandler: DeeplinkHandler {
    let next: DeeplinkHandler?

    func handle(_ url: URL) -> Bool {
        if url.path == "/profile" {
            print("Open profile")
            return true // ✅ Handled: stop chain
        }

        return next?.handle(url) ?? false
    }
}

struct SettingsDeeplinkHandler: DeeplinkHandler {
    let next: DeeplinkHandler?

    func handle(_ url: URL) -> Bool {
        if url.path == "/settings" {
            print("Open settings")
            return true
        }

        return next?.handle(url) ?? false // ✅ Forward to next handler
    }
}

let router = ProfileDeeplinkHandler(
    next: SettingsDeeplinkHandler(next: nil)
)

let handled = router.handle(URL(string: "myapp://open/settings")!)
print("Handled:", handled)

Run compiles and executes on the server; output shows below.

Adding a new deeplink usually means adding one handler and placing it in the correct order, not rewriting one central router.

Why it matters

Knowing pattern names is useful, but real implementation examples are much more valuable in an interview. They show that the candidate can apply the idea, not just define it.

  • Patterns often make the solution more scalable by separating construction, adaptation, observation, or routing rules from feature code.
  • Pattern names make design easier to communicate: "adapter around Mixpanel and Facebook" is clearer than explaining every wrapper from scratch.
  • Naming common pitfalls shows experience: singleton initialization order, thread safety, observer lifetimes, facade god objects, and chain ordering bugs.

Interview angle

Do not recite a catalog of patterns. Start with a short definition, then move quickly to examples you have actually seen or implemented.

  1. Group the patterns briefly: creational, structural, and behavioral.
  2. Give examples you implemented or saw in SDKs: URLComponents as Builder, UNUserNotificationCenter.current() or analytics SDKs as Singleton-like shared access, Mixpanel/Facebook wrappers as Adapter, Combine subscriptions as Observer, JSONEncoder.DateEncodingStrategy as Strategy, and deeplink handlers as Chain of Responsibility.
  3. Explain why the pattern helped: less duplicated setup, cleaner dependency direction, easier testing, or clearer routing rules.
  4. Name the cost: singleton initialization order, shared mutable state, observer lifetimes, facade god objects, adapter maintenance, or chain ordering bugs.

The strongest answer sounds like production experience: "I used this pattern here, it solved this scaling problem, and this is the pitfall I watched for."