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
- The POP Solution: Composing Behaviour with Protocols
- Protocol Extensions: Free Default Implementations
- The Mixin Pattern
- When Class Inheritance Genuinely Wins
- The Protocol-with-Associated-Types Limitation
- Performance Considerations
- When to Use (and When Not To)
- Summary
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 Protocolboxes the value into an existential container, which incurs a heap allocation. In hot paths (tight loops, per-frame rendering code), prefer generics withsome Protocolor concrete types overany Protocolto 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)
| Scenario | Recommendation |
|---|---|
| Modelling capabilities across unrelated types | POP: use protocols with default implementations |
| Shared mutable state observed by multiple owners | OOP: 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 own | POP: protocol extension (retroactive modelling) |
| Single-axis specialisation with stable base contract | OOP: abstract base class pattern |
| Eliminating boilerplate across unrelated types | POP: mixin pattern via protocol extension |
| Hot paths where allocation/dispatch cost matters | POP with generics: prefer some Protocol over any Protocol |
class A and class B share 2 of 5 methods | POP: extract shared methods into a protocol, drop the base class |
| Writing a new framework API for external consumers | Both: 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.