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.shared call 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 super calls to remember. If you’re overriding a method to inject a step, a protocol default is cleaner.
  • Visitor — Swift’s enums, pattern matching, and switch do double dispatch better. switch over 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 init with 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.