Skip to content

04 · Design Patterns in Swift

Classic object-oriented design patterns still apply in Swift, but Swift's value types, protocols, and closures often give you a lighter-weight way to express the same idea than the class-hierarchy version from a Java or C++ textbook. This module walks through four patterns you'll actually reach for.

Singleton

A singleton guarantees exactly one shared instance, reached through a static property. static let is thread-safe by construction — Swift initializes it lazily, exactly once, even under concurrent access:

final class Logger {
    static let shared = Logger()
    private init() {}   // prevents `Logger()` from outside
    private(set) var messages: [String] = []
    func log(_ message: String) { messages.append(message) }
}

Logger.shared.log("App started")
Logger.shared.log("User signed in")
print("Logged messages:", Logger.shared.messages)

Singletons are convenient but make testing harder (shared global state persists between tests) — prefer passing a Logger instance explicitly where you can, and reserve real singletons for things that are genuinely unique per process, like a shared URLSession or a log destination.

Factory

A factory centralizes the logic for choosing which concrete type to create, so callers depend only on a protocol:

protocol Shape { func area() -> Double }
struct Circle: Shape { let radius: Double; func area() -> Double { .pi * radius * radius } }
struct Square: Shape { let side: Double; func area() -> Double { side * side } }

enum ShapeKind { case circle(radius: Double), square(side: Double) }

enum ShapeFactory {
    static func make(_ kind: ShapeKind) -> Shape {
        switch kind {
        case .circle(let radius): return Circle(radius: radius)
        case .square(let side): return Square(side: side)
        }
    }
}

let shapes = [ShapeFactory.make(.circle(radius: 2)), ShapeFactory.make(.square(side: 3))]
for shape in shapes {
    print(String(format: "Area: %.2f", shape.area()))
}

Using an enum as the factory's input (rather than exposing Circle and Square directly) keeps construction details out of calling code — adding a new shape case means changing the factory in one place.

Observer

The Observer pattern lets one object broadcast changes to any number of interested listeners without knowing who they are. Swift's didSet property observer is a natural hook for this:

protocol PriceObserver: AnyObject { func priceDidChange(to price: Double) }

final class Stock {
    private var observers: [PriceObserver] = []
    var price: Double = 0 {
        didSet { observers.forEach { $0.priceDidChange(to: price) } }
    }
    func subscribe(_ observer: PriceObserver) { observers.append(observer) }
}

final class PriceLogger: PriceObserver {
    func priceDidChange(to price: Double) { print("Price changed to \(price)") }
}

let stock = Stock()
let priceLogger = PriceLogger()
stock.subscribe(priceLogger)
stock.price = 101.5
stock.price = 99.0

PriceObserver: AnyObject constrains the protocol to classes, which is necessary here because Stock stores observers by reference and needs identity semantics (to eventually support unsubscribe, for example) — Combine's @Published and SwiftUI's observation machinery are built on the same underlying idea.

Strategy

Strategy swaps out an algorithm at runtime behind a shared interface. In Swift, a protocol (or often just a closure) is the interface:

protocol DiscountStrategy { func apply(to total: Double) -> Double }
struct NoDiscount: DiscountStrategy { func apply(to total: Double) -> Double { total } }
struct PercentOff: DiscountStrategy {
    let percent: Double
    func apply(to total: Double) -> Double { total * (1 - percent / 100) }
}

struct Cart {
    var total: Double
    var strategy: DiscountStrategy
    func finalPrice() -> Double { strategy.apply(to: total) }
}

let cart = Cart(total: 200, strategy: PercentOff(percent: 10))
print("Final price:", cart.finalPrice())

Full output

Running all four sections together (swift design-patterns.swift):

Logged messages: ["App started", "User signed in"]
Area: 12.57
Area: 9.00
Price changed to 101.5
Price changed to 99.0
Final price: 180.0

Swift-specific traps

  • static let is the idiomatic singleton — don't hand-roll one with dispatch_once-style code (a pattern you may see in old Objective-C-derived tutorials); Swift's static stored properties are already lazy and thread-safe.
  • Observer protocols usually need AnyObject. Forgetting the class constraint means you can't store observers in a way that supports identity comparison or weak references, and Swift will let you write code that silently retains observers forever (a memory leak) if you're not careful — consider wrapping observer references as weak in a real implementation.
  • A struct-based Strategy loses the "reconfigure later" flexibility a class gives you for free, but that's often a feature: Cart above is copied by value, so two carts never accidentally share a discount strategy instance's mutable state.
  • Factories that return a protocol type erase the concrete type — code calling ShapeFactory.make cannot later do if let circle = shape as? Circle without an explicit downcast, which is usually the point, but can surprise people expecting the concrete type back.

Cheat sheet

Pattern Swift idiom
Singleton static let shared = Type() + private init()
Factory An enum or function returning a protocol type
Observer A protocol (AnyObject-constrained) + a list of subscribers
Strategy A protocol (or closure) stored as a property and swapped at runtime

How It Actually Works

  • Protocol-based patterns (Strategy, Observer) dispatch through the protocol witness table, not a vtable. Every concrete type conforming to a protocol gets a compiler-generated witness table — an array of function pointers, one per protocol requirement, pointing at that specific type's implementation. Calling a method on a value typed as the protocol looks up the right implementation through this table rather than through inheritance, which is why completely unrelated types (a struct and a class, say) can both implement the same "Strategy" protocol with zero shared ancestry — the witness table is generated independently per conforming type.
  • Singletons (static let shared = ...) rely on Swift's compiler-enforced guarantee that a static let's initializer runs exactly once, thread-safely, via a hidden dispatch_once-style atomic flag the compiler inserts automatically — you get thread-safe lazy initialization without writing any locking code yourself, which is different from other languages where a singleton's thread safety is something you must hand-implement.
  • Delegate pattern with weak var delegate: SomeDelegate? needs its protocol declared AnyObject-constrained specifically so the compiler can store it as a weak reference — weak requires reference-counted storage (a class or class-constrained existential), because weak references work by registering into the referenced object's ARC side table (see the memory management chapter), which only exists for class instances.
  • Existential containers: a variable typed as a protocol (not some Protocol, but a bare any Protocol / plain protocol type) is stored as an "existential container" — a fixed-size inline buffer (3 words) that either holds a small conforming value directly or, if the value is too large, a pointer to a heap-boxed copy, plus a pointer to that value's witness table. This indirection is what protocol-typed variables/arrays cost over a generic-constrained (some Protocol) alternative, which the compiler can instead specialize and avoid the container entirely.

Exercise

Implement the Decorator pattern: define a protocol Coffee { func cost() -> Double; func description() -> String }, a base struct Espresso: Coffee returning 1.50 and "Espresso", and two decorator structs — MilkAdded and SyrupAdded — that each wrap an existing Coffee value, add to its cost, and append to its description. Compose SyrupAdded(MilkAdded(Espresso())) and print both its final cost and full description.