iOS design patterns: Factory, Builder, Coordinator & Strategy
What these patterns solve, Swift examples, trade-offs and why Singleton does not replace dependency injection.
Start with the problem
A pattern is a vocabulary for recurring trade-offs, not a requirement for a 'senior' codebase. Use the simplest structure that keeps responsibilities clear. Factory builds objects; Builder assembles complex configuration; Coordinator owns navigation; Strategy swaps behaviors; Singleton exposes one shared instance.
Factory — isolate object construction
struct AccountsViewModel {
let repository: any AccountsRepository
}
protocol AccountsRepository { }
struct LiveRepository: AccountsRepository { }
enum AccountsFactory {
static func makeLiveViewModel() -> AccountsViewModel {
AccountsViewModel(repository: LiveRepository())
}
}Factory trade-off: a simple factory method is often enough. Avoid a sprawling factory hierarchy; construction logic is still code that needs tests.
Builder — construct a complex configuration step by step
import Foundation
struct RequestConfiguration {
let baseURL: URL
let timeout: TimeInterval
let cacheEnabled: Bool
}
struct RequestBuilder {
let baseURL: URL
var timeout: TimeInterval = 30
var cacheEnabled = false
func build() -> RequestConfiguration {
RequestConfiguration(baseURL: baseURL, timeout: timeout,
cacheEnabled: cacheEnabled)
}
}Builder trade-off: don't use it for two simple initializer parameters. Use it when optional combinations or incremental construction genuinely improve readability.
Coordinator — move navigation decisions out of feature views
import SwiftUI
@MainActor @Observable
final class AppCoordinator {
var path: [Route] = []
enum Route: Hashable { case account(String), settings }
func showAccount(id: String) { path.append(.account(id)) }
func goBack() { if !path.isEmpty { path.removeLast() } }
}
// Bind path to NavigationStack(path:); destinations map Route to views.Coordinator trade-off: useful when multi-step flows and deep links grow; unnecessary for a single small screen. NavigationStack can already manage simple flows without an extra abstraction.
Strategy — encapsulate alternative behavior
protocol FeeStrategy {
func fee(for amount: Decimal) -> Decimal
}
struct FlatFee: FeeStrategy {
func fee(for amount: Decimal) -> Decimal { 2 }
}
struct PercentageFee: FeeStrategy {
func fee(for amount: Decimal) -> Decimal { amount * 0.02 }
}
struct Checkout {
let fees: any FeeStrategy
func total(_ amount: Decimal) -> Decimal {
amount + fees.fee(for: amount)
}
}Singleton — not a DI substitute
A static shared instance may be suitable for immutable process-wide infrastructure, but globally mutable state obscures dependencies and can make tests depend on execution order. Prefer injecting the required collaborator explicitly.
| Pattern | Question it answers | Avoid when |
|---|---|---|
| Factory | Who creates the feature? | One trivial initializer |
| Builder | How are many options composed? | No configuration complexity |
| Coordinator | Who owns navigation decisions? | A tiny self-contained view |
| Strategy | How do we swap an algorithm? | Only one fixed behavior |
| Singleton | Is one process-wide instance required? | Convenience hides mutable state |
Personal study notes. Check details against your target Swift/iOS version and official documentation.