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.
Swift and iOS contain many everyday examples: URLComponents behaves like a builder, NotificationCenter is an observer-style API, delegates are callback-oriented behavioral patterns, and SDK wrappers often become adapters or facades. Some patterns are used rarely in modern Swift, or they look different because Swift favors protocols, structs, extensions, generics, and first-class functions over inheritance-heavy object-oriented designs.
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 hides the concrete type behind a creator API. Call sites ask for something useful, such as a UIViewController, without knowing exactly which subclass will be built.
On iOS, storyboards and nibs are a familiar example: the storyboard acts as the creator, and instantiateViewController(withIdentifier:) returns the concrete class configured in Interface Builder.
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.
import UIKit
func makeProfileViewController(from storyboard: UIStoryboard) -> UIViewController {
storyboard.instantiateViewController(withIdentifier: "ProfileViewController")
}
let profileViewController = makeProfileViewController(from: storyboard)
// Contrast: direct construction fixes the type at compile time.
let profileViewController = ProfileViewController()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.
The Builder pattern suggests that you extract the object construction code out of its own class and move it to separate objects called builders.
Why use it:
- To avoid a telescoping constructor with too many optional parameters.
- To create different representations of the same product using the same steps.
- To construct composite trees or other complex objects step by step.
In this example, api is preconfigured once and passed into another scope that adds only the endpoint-specific pieces:
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.
Builders are useful when optional pieces accumulate and many call sites must follow the same construction rules. 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:
- One object owns process-wide state or external SDK setup.
- Call sites can access the same instance without passing it everywhere.
- Expensive setup can happen once and be reused.
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.
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.
Singletons become especially fragile when they need runtime configuration before use. If app startup forgets to call something like AnalyticsTracker.configure(apiKey:) before screens start tracking, early events may be dropped, misdelivered, or silently ignored.
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.
In iOS code, this often means wrapping SDKs or platform APIs behind an app-owned protocol. For example, Mixpanel accepts string event names and Mixpanel properties, while Firebase Analytics uses FirebaseAnalytics.Analytics.logEvent(...) with its own event and parameter name types.
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 the SDK changes, you update the adapter instead of editing every screen that tracks analytics.
Facade
Facade gives a simple entry point over several subsystems. 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.
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)
}
}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.
Not runnable here — async network shape only.
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)
}
}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.
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: SettingsModel) {
self.viewModel = viewModel
viewModel.$theme
.dropFirst()
.sink { [weak self] newTheme in
self?.apply(theme: newTheme)
}
.store(in: &cancellables)
}
}Observers need clear lifetimes. Store AnyCancellable values, remove old observers when needed, and make sure screens do not keep receiving updates after teardown.
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.
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.
In app code, this is useful when the same model must be encoded differently for different APIs. Tests can inject a stable date strategy without changing the encoding code itself.
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.
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).
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.
The order matters: a more specific deeplink handler should usually appear before a broad fallback handler.
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.
- Group the patterns briefly: creational, structural, and behavioral.
- Give examples you implemented or saw in SDKs:
URLComponentsas Builder,UNUserNotificationCenter.current()or analytics SDKs as Singleton-like shared access, Mixpanel/Facebook wrappers as Adapter, Combine subscriptions as Observer,JSONEncoder.DateEncodingStrategyas Strategy, and deeplink handlers as Chain of Responsibility. - Explain why the pattern helped: less duplicated setup, cleaner dependency direction, easier testing, or clearer routing rules.
- 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."