Protocol-Oriented Programming vs OOP: When to Use Each in Swift


At WWDC 2015, Apple declared Swift “the first protocol-oriented programming language” — a bold claim that sent the community rushing to rewrite class hierarchies as protocol compositions. A decade later, plenty of codebases have overcorrected, replacing reasonable inheritance with needlessly abstract protocol webs.

This post gives you a practical decision framework: when does POP genuinely win over OOP, when does class inheritance still make sense, and how do you combine both without painting yourself into a corner.

Contents

The Problem: The Fragile Class Hierarchy

Imagine you’re modelling the characters in a Pixar studio pipeline — every character needs to be rendered, most can be animated, a subset can speak, and some (like Buzz’s rocket boots) can move independently. A classic OOP instinct produces something like this:

// Classic OOP hierarchy — looks tidy at first glance
class PixarCharacter {
    let name: String
    let studio: String

    init(name: String, studio: String) {
        self.name = name
        self.studio = studio
    }

    func render() {
        print("Rendering \(name) in RenderMan...")
    }
}

class AnimatedCharacter: PixarCharacter {
    var currentFrame: Int = 0

    func animate(to frame: Int) {
        currentFrame = frame
        print("\(name) animated to frame \(frame)")
    }
}

class TalkingCharacter: AnimatedCharacter {
    var voiceActor: String

    init(name: String, studio: String, voiceActor: String) {
        self.voiceActor = voiceActor
        super.init(name: name, studio: studio)
    }

    func speak(_ line: String) {
        print("\(name) (\(voiceActor)): \(line)")
    }
}

// Woody is both animated and talking — fine so far
let woody = TalkingCharacter(name: "Woody", studio: "Pixar", voiceActor: "Tom Hanks")
woody.speak("There's a snake in my boot!")

This works until the hierarchy meets the real world. The gorilla-banana problem kicks in the moment you need a character that can move but doesn’t need to be animated on a timeline — say, a background prop with physics. You wanted a banana, but you got the gorilla and the entire jungle:

// Problem: WALL-E can move autonomously but is NOT an AnimatedCharacter
// in the traditional keyframe sense. To get `move()`, we're forced to
// inherit everything from TalkingCharacter — including voiceActor
// and speak() — which makes no sense for a robot.

class MovableCharacter: TalkingCharacter {
    var position: CGPoint = .zero

    func move(to point: CGPoint) {
        position = point
        print("\(name) moved to \(point)")
    }
}

// WALL-E has a voiceActor property he doesn't need
// and a speak() method that shouldn't be callable.
let wallE = MovableCharacter(name: "WALL-E", studio: "Pixar", voiceActor: "")
wallE.speak("Eeeeva?") // This compiles — but it violates our model

The fragile base class problem compounds this: changing PixarCharacter.render() to be async breaks every subclass. Changing AnimatedCharacter to store a [CGFloat] curve instead of a single Int frame requires auditing every subclass. The hierarchy has become load-bearing infrastructure that’s expensive to change.

The POP Solution: Composing Behaviour with Protocols

Instead of asking “what is this character?” (inheritance), POP asks “what can this character do?” (capability). Each capability becomes a protocol:

// Each capability is a standalone contract
protocol Renderable {
    var name: String { get }
    func render()
}

protocol Animatable {
    var currentFrame: Int { get set }
    mutating func animate(to frame: Int)
}

protocol Talkable {
    var voiceActor: String { get }
    func speak(_ line: String)
}

protocol Movable {
    var position: CGPoint { get set }
    mutating func move(to point: CGPoint)
}

Concrete types opt in to exactly the capabilities they need — nothing more:

// Woody: animated, talkable, renderable. Not independently movable.
struct WoodenCowboy: Renderable, Animatable, Talkable {
    let name: String
    let voiceActor: String
    var currentFrame: Int = 0

    func render() {
        print("Rendering \(name) with RenderMan subsurface scattering...")
    }

    mutating func animate(to frame: Int) {
        currentFrame = frame
    }

    func speak(_ line: String) {
        print("\(name) (\(voiceActor)): \(line)")
    }
}

// WALL-E: renderable, movable. No voice actor, no keyframe animation.
struct CompactRobot: Renderable, Movable {
    let name: String
    var position: CGPoint = .zero

    func render() {
        print("Rendering \(name) with metallic shader...")
    }

    mutating func move(to point: CGPoint) {
        position = point
        print("\(name) rolled to \(point)")
    }
}

var woody = WoodenCowboy(name: "Woody", voiceActor: "Tom Hanks")
var wallE = CompactRobot(name: "WALL-E")

woody.speak("You've got a friend in me.")
wallE.move(to: CGPoint(x: 42, y: 100))

CompactRobot cannot call speak() — the compiler enforces that. You’ve gained composability (mix and match capabilities freely), isolation (changing Animatable doesn’t affect Movable), and value-type safety (both are structs, so mutations are explicit and copies are cheap).

Protocol Extensions: Free Default Implementations

One of POP’s most powerful features is the ability to ship default implementations via protocol extensions. Any conforming type can override them, but most won’t need to:

extension Renderable {
    // Default implementation: any Renderable gets this for free
    func render() {
        print("Rendering \(name) with default pipeline...")
    }

    // Computed property available to all Renderables
    var renderDescription: String {
        "[\(name): queued for render]"
    }
}

extension Animatable {
    // Default no-op animation — useful for static props
    mutating func animate(to frame: Int) {
        currentFrame = frame
        // No-op: subclasses override if they need visual feedback
    }
}

extension Talkable where Self: Renderable {
    // Constrained extension: only available when BOTH Talkable AND Renderable
    func performScene(_ line: String) {
        render()
        speak(line)
    }
}

The constrained extension on the last block is especially powerful. performScene(_:) is only available on types that are both Talkable and Renderable — the compiler enforces this at the call site, not at runtime.

struct SpaceRanger: Renderable, Animatable, Talkable, Movable {
    let name: String
    let voiceActor: String
    var currentFrame: Int = 0
    var position: CGPoint = .zero

    // render() is inherited from the Renderable extension — no need to implement
    // animate(to:) is also inherited from Animatable extension

    func speak(_ line: String) {
        print("\(name) (\(voiceActor)): \(line)")
    }

    mutating func move(to point: CGPoint) {
        position = point
    }
}

var buzz = SpaceRanger(name: "Buzz Lightyear", voiceActor: "Tim Allen")
buzz.performScene("To infinity — and beyond!")
// Outputs:
// Rendering Buzz Lightyear with default pipeline...
// Buzz Lightyear (Tim Allen): To infinity — and beyond!

The Mixin Pattern

Protocol extensions enable a mixin pattern: shared behaviour that types gain just by conforming, without subclassing. This is how Swift’s standard library ships Equatable, Comparable, and Hashable default implementations.

// A "Logging" mixin — any type that conforms gets structured logging for free
protocol ProductionLoggable {
    var logIdentifier: String { get }
}

extension ProductionLoggable {
    func log(_ event: String, level: LogLevel = .info) {
        print("[\(level)] [\(logIdentifier)] \(event)")
    }
}

enum LogLevel: String {
    case info = "INFO"
    case warning = "WARN"
    case error = "ERROR"
}

// RenderJob gains logging without inheriting from any base class
struct RenderJob: ProductionLoggable {
    let jobID: UUID
    let filmTitle: String

    var logIdentifier: String { "RenderJob(\(filmTitle))" }
}

let job = RenderJob(jobID: UUID(), filmTitle: "Finding Nemo")
job.log("Started render pass 1 of 12")
job.log("GPU memory pressure high", level: .warning)
// [INFO] [RenderJob(Finding Nemo)] Started render pass 1 of 12
// [WARN] [RenderJob(Finding Nemo)] GPU memory pressure high

Any struct, class, or enum can conform to ProductionLoggable. No shared base class required.

When Class Inheritance Genuinely Wins

POP is not always the answer. There are four scenarios where class inheritance is the correct tool:

1. Reference semantics are required. When multiple parts of your system need to observe mutations to the same object, you need a class. A RenderSession shared across a coordinator, a progress reporter, and a cancellation handler should be a class — copying it would break the shared-state contract.

// Shared mutable state: class is correct here
final class RenderSession {
    private(set) var progress: Double = 0.0
    private(set) var isCancelled: Bool = false

    func updateProgress(_ value: Double) {
        progress = min(1.0, max(0.0, value))
    }

    func cancel() {
        isCancelled = true
    }
}

// Multiple observers hold a reference to the SAME instance
let session = RenderSession()
let progressBar = ProgressMonitor(session: session)
let cancelButton = CancelHandler(session: session)
// When session.cancel() is called, both progressBar and cancelButton see it

2. Objective-C interoperability. Any type that needs to be visible to Objective-C code must be a class (or a struct bridged through @objc). Delegates, NSObject subclasses, and anything used with KVO must be classes.

3. Framework-mandated subclassing. UIView, UIViewController, UITableViewCell, CALayer, NSManagedObject — these are classes because the frameworks require it. Don’t fight the framework. Your PixarFilmCell: UITableViewCell is correct as a class.

// NSManagedObject subclassing: class is mandatory
class PixarFilm: NSManagedObject {
    @NSManaged var title: String
    @NSManaged var releaseYear: Int16
    @NSManaged var boxOfficeGross: Double
}

4. Single-axis specialisation with stable contracts. When you have a clear “is-a” relationship that will never branch, inheritance is elegant. An AbstractRenderPass with ShadowRenderPass, AmbientOcclusionRenderPass, and ReflectionRenderPass is a valid hierarchy because render passes genuinely share an interface and a single axis of variation.

The Protocol-with-Associated-Types Limitation

Protocols with associated types (PATs) introduce a significant constraint: you cannot use them directly as an existential type (in Swift 5.6 and below, you couldn’t write let items: [Animatable] if Animatable had an associated type). Swift 5.7 introduced any and some keywords to handle this, but the limitation still affects API design.

protocol SceneAsset {
    associatedtype AssetData
    func loadAsset() async throws -> AssetData
}

struct FilmReel: SceneAsset {
    let title: String
    func loadAsset() async throws -> Data {
        Data() // Simplified for clarity
    }
}

// Swift 5.7+: use `any SceneAsset` for heterogeneous collections
// Prior to 5.7, this was a compile error
func processAssets(_ assets: [any SceneAsset]) {
    // You can iterate, but you cannot call loadAsset() here without
    // type erasure — the associated type is opaque at this level
    print("Processing \(assets.count) assets")
}

// Use `some` for return types when the concrete type is known
func makeFilmAsset() -> some SceneAsset {
    return FilmReel(title: "Coco")
}

Warning: Using any Protocol boxes the value into an existential container, which incurs a heap allocation. In hot paths (tight loops, per-frame rendering code), prefer generics with some Protocol or concrete types over any Protocol to avoid unnecessary allocations.

Performance Considerations

The dispatch mechanism matters at scale.

Static dispatch (structs, final classes, generic constraints) is resolved at compile time. The compiler can inline the call, enabling significant optimisations. Value types stored inline (not boxed) also benefit from cache locality.

Dynamic dispatch (class methods via vtable, protocol existentials via witness table) is resolved at runtime. Each call involves an indirect function pointer lookup. This is negligible in most UI code but measurable in render loops, audio processing, or tight data-pipeline work.

// Static dispatch — the compiler can inline this entirely
func renderAll<T: Renderable>(_ items: [T]) {
    for item in items { item.render() }
}

// Dynamic dispatch via existential — witness table lookup per call
func renderAllDynamic(_ items: [any Renderable]) {
    for item in items { item.render() }
}

For a render job processing 10,000 assets per frame, the difference between [T: Renderable] and [any Renderable] can be measurable. Profile with Instruments before optimising, but understand which path you’re on.

Apple Docs: Choosing Between Structures and Classes — Swift Standard Library

Value types also avoid the overhead of reference counting (retain/release calls). In collections of thousands of small objects, eliminating ARC churn is often a bigger win than dispatch strategy.

When to Use (and When Not To)

ScenarioRecommendation
Modelling capabilities across unrelated typesPOP: use protocols with default implementations
Shared mutable state observed by multiple ownersOOP: class with reference semantics
Objective-C interop (delegates, KVO, @objc)OOP: class, period
Framework-mandated subclassing (UIView, NSManagedObject)OOP: subclass as required
Adding behaviour to existing types you don’t ownPOP: protocol extension (retroactive modelling)
Single-axis specialisation with stable base contractOOP: abstract base class pattern
Eliminating boilerplate across unrelated typesPOP: mixin pattern via protocol extension
Hot paths where allocation/dispatch cost mattersPOP with generics: prefer some Protocol over any Protocol
class A and class B share 2 of 5 methodsPOP: extract shared methods into a protocol, drop the base class
Writing a new framework API for external consumersBoth: protocols for API surface, classes for implementations

The honest summary: POP wins when you’re describing capabilities; OOP wins when you’re describing identity and shared state. In practice, production iOS codebases use both — SwiftUI’s View protocol is POP at the API boundary, while UIApplication and AppDelegate are classic OOP under the hood.

Summary

  • Class hierarchies create tight coupling — the gorilla-banana problem and the fragile base class problem are real costs that compound over time.
  • Protocol composition describes capabilities rather than identity, enabling types to mix and match behaviours without inheritance chains.
  • Protocol extensions deliver default implementations and enable the mixin pattern — any conforming type gets the behaviour for free, and can override selectively.
  • Class inheritance still wins for reference semantics, Objective-C interop, framework-mandated subclassing (UIView, NSManagedObject), and stable single-axis specialisation hierarchies.
  • Performance: generic constraints (some Protocol) give the compiler full type information for static dispatch and inlining; existentials (any Protocol) incur witness-table lookup and heap boxing.

Understanding the dispatch mechanics behind your abstractions unlocks the next level — Advanced Generics digs into opaque return types, primary associated types, and the full some/any story.