Design Patterns in Swift: A Practical Guide
I have a confession: I learned design patterns from the Gang of Four book, and I still think about them almost every day — but mostly as a checklist of what not to do. When I started iOS development, I was building AbstractVehicleFactory hierarchies that would have made the book proud and any reviewer weep. Swift has a way of exposing that kind of ceremony for what it is.
Here’s the thing nobody tells you: Swift’s own language features — protocols, extensions, value types, generics, closures, async/await, and Combine — quietly replaced or reshaped most of the classic patterns. The GoF book was written for C++ in 1994. Swift is protocol-oriented, and that changes the calculus for nearly every pattern in the catalog.
So let me walk you through the patterns that still matter on a modern iOS codebase, what they look like in idiomatic Swift, and — honestly — which ones you should stop reaching for. Consider this the conversation I wish someone had with me in 2016.
1. Singleton: The Pattern Everyone Regrets
The Singleton is the most famous pattern in the book, and the most abused. The core idea — a class with exactly one instance — is still legitimate for genuinely shared infrastructure: logging, analytics, crash reporting. Apple ships singletons everywhere: UserDefaults.standard, FileManager.default, URLSession.shared.
The canonical Swift version:
final class UserDefaultsStore {
static let shared = UserDefaultsStore()
private init() {}
var isOnboarded: Bool {
get { UserDefaults.standard.bool(forKey: "isOnboarded") }
set { UserDefaults.standard.set(newValue, forKey: "isOnboarded") }
}
}
This compiles, and it’s fine — until it isn’t. The problems start when singletons hold mutable state and get referenced directly from every layer of the app:
- Hidden coupling. Every
UserDefaultsStore.sharedcall is a hidden dependency. You can’t tell what a type needs by looking at its initializer. - Untestable. Try writing a unit test that isolates a view model that pokes a global. You can’t reset the singleton between tests without exposing its internals.
- Global mutable state. Two features writing to the same shared object produce nondeterministic bugs. I once spent a day chasing a crash that only happened when the onboarding flow ran concurrently with the sync engine — both mutating the same shared session object.
Also — a subtle one — singletons live for the app’s lifetime, which means anything they capture stays alive forever. That’s a direct path to retain cycles and leaked view controllers, as I covered in detail in iOS Memory Management: From ARC to Retain Cycles.
The modern fix: keep the singleton for construction, but inject it everywhere.
protocol OnboardingStoring {
var isOnboarded: Bool { get set }
}
extension UserDefaultsStore: OnboardingStoring {}
final class OnboardingViewModel {
private let store: any OnboardingStoring
init(store: any OnboardingStoring = UserDefaultsStore.shared) {
self.store = store
}
}
Now tests inject a mock. Production gets the convenience. The singleton becomes an implementation detail instead of an architectural decision.
Verdict: use it — narrowly, and always behind a protocol. If a singleton holds mutable state, you have a bug waiting to happen.
2. Observer: From NotificationCenter to Combine to AsyncSequence
The GoF Observer pattern decouples a subject from its observers. In old-school iOS, that meant NotificationCenter with stringly-typed names:
// Posting
NotificationCenter.default.post(name: .userDidLogin, object: nil, userInfo: ["user": user])
// Observing — the observer must remember to remove itself in deinit
NotificationCenter.default.addObserver(
self,
selector: #selector(handleLogin(_:)),
name: .userDidLogin,
object: nil
)
Three problems: no type safety (anyone can post the wrong userInfo), selector-based and awkward, and observers leak if you forget to remove them. If you ever see a deinit full of removeObserver calls, that’s the pattern showing its age.
Modern Swift gives you two upgrades. First, Combine turns every notification into a typed stream:
import Combine
var cancellables = Set<AnyCancellable>()
NotificationCenter.default
.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { _ in
print("App went to background")
}
.store(in: &cancellables)
No selectors, no manual removal, and the AnyCancellable lifecycle ties observation to the owning object. Second, iOS 15+ added notifications(named:) on NotificationCenter, giving you an AsyncSequence you can for await over in a task:
Task {
for await _ in NotificationCenter.default.notifications(named: .userDidLogin) {
await refreshSession()
}
}
But honestly, most of the time you don’t want NotificationCenter at all. If the observer and subject are in the same module, a @Published property or a PassthroughSubject gives you the same decoupling with full type safety — no stringly-typed names, no userInfo dictionaries:
final class SessionManager: ObservableObject {
@Published private(set) var user: User?
func login(_ user: User) {
self.user = user
}
}
SwiftUI views observe it directly; UIKit screens subscribe with sink. One object, typed observation, zero ceremony. Reserve NotificationCenter for genuinely app-wide events where the poster doesn’t know or care who’s listening — and I’d argue that’s a smaller set than most codebases think.
Verdict: use it — but prefer @Published/@Observable or subjects for typed, local observation. NotificationCenter survives for cross-module events; Combine’s publisher bridge makes even that tolerable.
3. Delegate: Still Great, Just No Longer the Default
The Delegate pattern — one object handing responsibility for decisions and events to another — is baked into UIKit’s DNA. UITableViewDataSource, UITextFieldDelegate, URLSessionDelegate. The classic shape:
protocol ImageLoaderDelegate: AnyObject {
func imageLoader(_ loader: ImageLoader, didLoad image: UIImage)
func imageLoader(_ loader: ImageLoader, didFailWith error: Error)
}
final class ImageLoader {
weak var delegate: (any ImageLoaderDelegate)?
}
Note the weak — delegate references must be weak or you get a retain cycle, something every iOS dev has debugged at 2 a.m. The pattern is fine, but for a single result, it’s heavyweight: you need a protocol, a weak property, and two methods where all you wanted was a value.
Closures collapsed this to one property:
final class ImageLoader {
var onImage: ((UIImage) -> Void)?
var onError: ((Error) -> Void)?
}
And async/await collapsed it further — now you just return the value:
final class ImageLoader {
func loadImage(from url: URL) async throws -> UIImage {
// fetch and decode...
}
}
// Call site:
let image = try await imageLoader.loadImage(from: url)
No protocol, no weak dance, no callback threading. The async version also gives you cancellation for free with Task cancellation.
When do I still write delegates? Two cases. First, long-lived relationships with many callbacks and lifecycle awareness — that’s exactly why URLSession and Core Location still use delegate APIs. Second, when the API is Apple’s own: you’re not going to rewrite UITableViewDataSource — the same idea survives in SwiftUI’s builder-based APIs like Table (macOS), and UIKit’s UIPickerViewDataSource carries it forward unchanged.
Verdict: skip it for one-shot results — async/await took that job. Keep it for multi-callback, lifecycle-bound relationships, where it remains the right tool.
4. Abstract Factory: Protocols and Generics Ate It
The GoF Abstract Factory exists so you can create families of related objects without naming concrete classes. In Swift, we have a better primitive: the protocol itself. You don’t need a factory hierarchy — you need an interface and an implementation you can swap.
protocol DataServicing {
func fetchItems() async throws -> [Item]
}
struct LiveDataService: DataServicing {
func fetchItems() async throws -> [Item] {
// URLSession, decoding, the works
}
}
struct MockDataService: DataServicing {
func fetchItems() async throws -> [Item] {
[Item(id: 1, name: "Preview data")]
}
}
The “factory” becomes a single function that decides which implementation to hand out — often at the composition root:
enum ServiceFactory {
static func makeDataService(for environment: AppEnvironment) -> any DataServicing {
switch environment {
case .production: return LiveDataService()
case .uiTesting: return MockDataService()
}
}
}
Note the any keyword — required in Swift 6’s language mode and the clear convention since Swift 5.6 (SE-0335). It’s a nice forcing function: it reminds you that you’re dealing with a boxed protocol, which has a small performance cost. When you want the full generic treatment, protocols with associated types plus generics handle the type-safe cases that used to require a maze of factory classes. This is exactly the shift I explored in Why Swift Is Protocol-Oriented (And Why That Matters) — the factory’s intent (depend on abstractions) is preserved, but its scaffolding is gone.
This is also the pattern that makes dependency injection work in practice: MVVM with Clean Architecture in iOS leans on exactly this trick to swap repositories and use cases between environments.
Verdict: use the intent, skip the ceremony. A factory function returning any SomeProtocol is the modern Abstract Factory.
5. Strategy: Protocols Plus Value Semantics
The Strategy pattern encapsulates interchangeable algorithms behind an interface. Its classic Java form is a cluster of classes implementing an interface — and Swift does that, but with a twist: strategies are usually structs. That value-type choice pays off doubly under Swift 6 strict concurrency: stateless strategy structs are implicitly Sendable, so they cross actor boundaries for free — something I explore in Struct vs Class in Swift 6: Concurrency-Safe Choices.
protocol PricingStrategy {
func total(for order: Order) -> Decimal
}
struct StandardPricing: PricingStrategy {
func total(for order: Order) -> Decimal {
order.items.reduce(Decimal.zero) { $0 + $1.price }
}
}
struct FreeShippingPricing: PricingStrategy {
func total(for order: Order) -> Decimal {
let subtotal = order.items.reduce(Decimal.zero) { $0 + $1.price }
return subtotal >= 50 ? subtotal : subtotal + Decimal(string: "5.99")!
}
}
The consumer just holds a strategy:
struct CheckoutViewModel {
private var pricing: any PricingStrategy
init(pricing: any PricingStrategy = StandardPricing()) {
self.pricing = pricing
}
mutating func useFreeShippingThreshold() {
pricing = FreeShippingPricing() // swap strategies at runtime
}
}
Because strategies are value types, swapping them is safe and thread-local — no shared mutable state, no locking. This is the payoff of Swift’s value semantics, which I dug into in Struct vs Class in Swift: Making the Right Choice. A struct-based strategy composes, copies, and tests like a value, not like a tangled object graph.
One caveat: if the strategy has associated state, make it a struct with value semantics anyway, or a final class behind a protocol — just don’t fall back to a giant if/else chain in the consumer. That’s the anti-pattern strategy exists to kill.
Verdict: use it — it’s one of the most natural fits in Swift. Protocol + struct strategy is idiomatic, testable, and trivially composable.
6. Facade: You Already Have It — It’s Your Module Boundary
The Facade pattern hides a complex subsystem behind a simple interface. In 1994 that meant a class wrapping other classes. In modern Swift, the most valuable facades are protocol-based module boundaries: the public protocol of a module is the facade, and everything else is internal.
// WeatherModule.swift — the public facade
public protocol WeatherProviding {
func currentWeather(at coordinate: CLLocationCoordinate2D) async throws -> Weather
}
// Everything below is internal — callers never see it.
struct WeatherService: WeatherProviding {
private let geocoder: any Geocoding // used by the geocoding subsystem
private let network: any Networking
private let cache: any Caching
func currentWeather(at coordinate: CLLocationCoordinate2D) async throws -> Weather {
if let cached = try cache.weather(for: coordinate) { return cached }
let response = try await network.fetch(WeatherEndpoint(coordinate: coordinate))
let weather = try Weather(response: response)
try cache.store(weather, for: coordinate)
return weather
}
}
That’s the whole point: callers see one protocol with one method, and the geocoder/network/cache subsystem stays private. You get the Facade’s benefit — a simple interface over complexity — plus Swift’s access control enforces it at compile time. Nobody can accidentally reach into WeatherService’s internals; the language won’t let them.
The classic mistake I see is treating Facade as an excuse for a “God object” — one facade that wraps everything (AppService with 40 methods). A good facade hides one cohesive subsystem. If it hides three, split it.
Verdict: use it — but let access control do the enforcing. One protocol per subsystem, everything else internal.
7. Coordinator: Navigation That Doesn’t Rotten-Apple
Coordinators aren’t in the GoF book (they’re an iOS invention from the early 2010s, popularized by Kyle Fuller’s 2013 writing on the pattern), but they solve a real problem: view controllers shouldn’t know how to navigate the app. In UIKit, the classic shape:
@MainActor
protocol Coordinating: AnyObject {
var childCoordinators: [any Coordinating] { get set }
func start()
}
@MainActor
final class AppCoordinator: Coordinating {
private let window: UIWindow
private let factory: any ScreenFactory
var childCoordinators: [any Coordinating] = []
init(window: UIWindow, factory: any ScreenFactory) {
self.window = window
self.factory = factory
}
func start() {
let navigation = UINavigationController()
let onboarding = factory.makeOnboardingScreen(onFinish: { [weak self] in
self?.showMainFlow()
})
navigation.viewControllers = [onboarding]
window.rootViewController = navigation
window.makeKeyAndVisible()
}
private func showMainFlow() {
// swap the root, add child coordinators, etc.
}
}
Two things make or break coordinators. First, the childCoordinators array — if you don’t keep strong references, coordinators deallocate the moment they’re created. Second, [weak self] in every callback, because the coordinator owns the flow, which owns the screens. I have debugged both. I have also seen teams overdo it — a coordinator per screen is cargo cult; a coordinator per flow (onboarding, auth, checkout) is right.
SwiftUI reshaped this pattern significantly. With NavigationStack(path:), navigation state becomes a value — an array of route enums — which is testable, serializable, and doesn’t need a coordinator object at all:
struct RootView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .detail(let id): ItemDetailView(id: id)
case .checkout: CheckoutView()
}
}
}
}
}
enum Route: Hashable {
case detail(id: Int)
case checkout
}
Deep links become path.append(.checkout) — no view controller hierarchy to manipulate. The coordinator pattern survives in SwiftUI mainly for flow orchestration: auth gating, multi-window, complex deep-link routing. The rest of the time, value-type navigation state replaces it. I covered the surrounding architecture in MVVM with Clean Architecture in iOS: A Practical Guide, where coordinators sit in the Presentation layer exactly where they belong.
Verdict: use it in UIKit (one per flow, not per screen); in SwiftUI, prefer path-based navigation and keep coordinators only for cross-cutting flow logic.
8. The Patterns You Can Safely Forget
Let me be blunt about the rest of the catalog, because I’ve seen teams reach for these:
- Template Method — replaced by protocol extensions with default implementations. No fragile base classes, no
supercalls to remember. If you’re overriding a method to inject a step, a protocol default is cleaner. - Visitor — Swift’s enums, pattern matching, and
switchdo double dispatch better.switchover an enum is exhaustive, compiler-checked, and doesn’t need a visitor hierarchy. - Mediator — Combine’s operators and structured concurrency handle “many objects coordinating” far more declaratively. The mediator classes I’ve seen were always just bad event plumbing in disguise.
- Builder — partially survives, but Swift’s memberwise initializers and default parameter values cover 90% of builder use cases. For the rest, a tiny
initwith defaults wins.
None of these are wrong; they’re just solving problems Swift’s own features already solved. Clinging to them is how you end up with a codebase that reads like C++ with Swift syntax.
Key Takeaways
- Swift’s language features replaced the scaffolding of most GoF patterns — protocols replaced abstract classes, value types replaced stateful objects, and async/await replaced callback plumbing. The intents survive; the ceremonies don’t.
- Singleton isn’t evil, but global mutable state is. Keep the shared instance, inject it behind a protocol, and your tests will thank you.
- Reach for async/await before closures, and closures before delegates — the shorter the callback chain, the better. Keep delegates for long-lived, lifecycle-aware APIs.
- Strategy and Factory are alive and well — they’re just protocols plus structs plus a factory function returning
any SomeProtocol. - Facade is best expressed as a module boundary — public protocol, everything else
internal, enforced by the compiler. - Coordinators are shrinking, not dying — UIKit flows still need them; SwiftUI’s
NavigationStack(path:)replaces most of their work with testable value-type navigation state. - Template Method, Visitor, and Mediator are retired — protocol defaults, enums with pattern matching, and Combine operators do the same jobs more idiomatically.
- The real pattern to follow is dependency direction: depend on abstractions, inject them from the outside, and let Swift’s type system enforce the rest.
The best design pattern for Swift is the one Swift itself points you toward: small protocols, value types, composition over inheritance, and explicit wiring at the edges. Everything else is commentary.