Protocols in Swift — The Feature That Makes iOS Architecture Work
Inheritance gives you a taxonomy. Protocols give you a contract. Only one of them scales.

Protocols in Swift — The Feature That Makes iOS Architecture Work
Inheritance gives you a taxonomy. Protocols give you a contract. Only one of them scales.
📚 Non Medium Member: Read here
Here’s a question many senior iOS developers struggle to answer cleanly:
Why does Apple consistently recommend protocols over base classes — and what does that actually mean in practice?
Not “use Codable and Equatable" — you already know that. I mean: what is the design philosophy that makes protocol-oriented programming a fundamentally different way of composing systems?
Why do associated types exist, and why do they cause the errors they do?
What are existentials, and why did Swift 5.7 need to partially reinvent them?
This article goes deep.
By the end, you’ll understand not just how to use protocols, but why they’re structured the way they are — and how that structure shapes nearly every serious iOS architecture.
Part 1: Protocol-Oriented Programming — What It Actually Means
Protocol-oriented programming (POP) is not simply “use protocols instead of classes.”
The real idea behind Swift’s design is much deeper: define behavior in terms of capabilities, not identity.
Consider the classical inheritance approach:
class Animal {
func speak() { }
}
class Dog: Animal {
override func speak() { print("Woof") }
}
class Robot { }
class RobotDog: Robot {
// Can't inherit from Animal - Swift has single inheritance
func speak() { print("Bleep bloop") }
}
You’ve modeled identity (what a thing is), and now you’re stuck. RobotDog and Dog both speak, but you can't express that relationship in the type system without a shared base class — which forces you to pick one parent and abandon the other.
Protocols model capability:
protocol Speakable {
func speak()
}
class Dog: Animal, Speakable {
func speak() { print("Woof") }
}
class RobotDog: Robot, Speakable {
func speak() { print("Bleep bloop") }
}
func makeNoise(_ thing: any Speakable) {
thing.speak()
}
Dog and RobotDog share no ancestry, yet both conform to Speakable — and that’s all makeNoise needs to know.
The function doesn’t care what a thing is. It only cares what it can do.
That distinction — capability vs. identity — is the core idea behind protocol-oriented programming.
It shifts your design from taxonomy to behavior, producing systems that are easier to extend and far easier to test.
Protocol Extensions: Behavior Without Inheritance
The second pillar of POP is protocol extensions — the ability to add default implementations to protocol requirements:
protocol Greetable {
var name: String { get }
func greet()
}
extension Greetable {
func greet() {
print("Hello, I'm \(name)")
}
}
struct Engineer: Greetable {
var name: String
// greet() is already implemented - no need to write it
}
This is genuinely powerful. It means you can ship behavior with a protocol — default implementations that conforming types inherit automatically, with no class hierarchy required.
Swift’s standard library uses this extensively: Equatable, Comparable, Sequence all provide rich default behavior through protocol extensions.
The critical distinction from class inheritance: protocol extension methods are statically dispatched unless they’re part of the protocol requirement itself. This matters for performance and for understanding method resolution when conforming types override defaults.
Part 2: Associated Types — Generic Protocols
Here’s where most Swift developers hit a wall:
protocol Repository {
associatedtype Model
func fetch(id: String) -> Model?
func save(_ model: Model)
}
An associatedtype is a placeholder type inside a protocol.
It says: "conforming types will specify what Model is."
This makes Repository a generic protocol — a protocol parameterized over a type.
Conformance looks like this:
struct UserRepository: Repository {
typealias Model = User
func fetch(id: String) -> User? { ... }
func save(_ model: User) { ... }
}
Swift can often infer the typealias from the method signatures, so you rarely need to write it explicitly.
Why Does This Break Things?
The moment a protocol has an associated type, you can’t use it as a plain type annotation:
// ❌ This doesn't compile
var repo: Repository
// ✅ This works
var repo: UserRepository
The error you’ll see is: “Protocol ‘Repository’ can only be used as a generic constraint because it has Self or associated type requirements.”
Why? Because Repository by itself is incomplete.
Repository of what?
The compiler needs to know the concrete Model type to lay out memory, dispatch methods, and perform type checking. A bare Repository without its associated type resolved is like a half-instantiated generic — it has no concrete representation.
This is the fundamental tension in Swift’s type system: protocols with associated types (PATs) are powerful, but they require type resolution at the point of use. Understanding this is the key to understanding existentials.
Part 3: Existentials — Boxing the Protocol
An existential is a way to use a protocol as a type when you need runtime polymorphism — when you genuinely don’t know the concrete type at compile time.
Before Swift 5.7, existential syntax was implicit:
// Old: implicitly an existential
var speaker: Speakable
Swift 5.7 introduced the any keyword to make existentials explicit:
// New: explicitly an existential
var speaker: any Speakable
The any keyword isn't just cosmetic. It's the compiler forcing you to acknowledge: "I'm paying the cost of dynamic dispatch and heap allocation here."
What’s the Cost?
Think of an existential as a shipping container for a value whose size isn’t known at compile time.
The container itself has a fixed, predictable size no matter what’s inside. Small values fit directly in the container. Larger values get stored elsewhere on the heap, and the container simply holds a reference to them.
That’s essentially how Swift handles any Protocol.
When you store a value inside an existential box, Swift uses a structure called an existential container.
Typically, it contains:
- 3 words for an inline value buffer (or a heap pointer if the value is too large)
- 1 word for type metadata
- 1 word for the witness table — a lookup table that maps protocol requirements to the concrete type’s implementations
This means:
- Small value types that fit in 3 words are stored inline — no heap allocation
- Larger value types get heap-allocated, referenced by pointer inside the container
- Every method call goes through the witness table — dynamic dispatch, not static
For hot paths in performance-sensitive code, this matters. For most application-layer code, it doesn’t. The point is to understand when you’re opting into this cost — which is exactly what the any keyword forces you to acknowledge at the call site.
any vs. some
Swift 5.1 introduced some for opaque return types:
// some: the compiler knows the exact type, you're just hiding it from callers
func makeRepository() -> some Repository { UserRepository() }
// any: the type is genuinely unknown at compile time
func process(repo: any Repository) { ... }
some is a compile-time abstraction — the concrete type is resolved once and fixed. some promises that all code paths return the same concrete type.
This is how SwiftUI's body property works: var body: some View tells the compiler there's a single concrete View type coming back, even though you're not naming it.
any is a runtime abstraction — the concrete type is unknown and can vary. Use any when you need a heterogeneous collection or truly late-bound polymorphism.
The guideline: prefer some when possible; reach for any when necessary.
Part 4: Dependency Injection — Protocols as Seams
Here’s where protocol design directly shapes architectural quality.
Dependency injection is the practice of passing a type’s dependencies in from outside rather than constructing them internally. Protocols make this possible by defining the interface a dependency must satisfy, without committing to a concrete implementation.
The classic example: networking.
// Define the capability
protocol HTTPClient {
func fetch(url: URL) async throws -> Data
}
// Production implementation
struct URLSessionClient: HTTPClient {
func fetch(url: URL) async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
// Test implementation
struct MockHTTPClient: HTTPClient {
var stubbedData: Data = Data()
var stubbedError: Error? = nil
func fetch(url: URL) async throws -> Data {
if let error = stubbedError { throw error }
return stubbedData
}
}
Now your view model takes any HTTPClient:
final class WeatherViewModel: ObservableObject {
private let client: any HTTPClient
init(client: any HTTPClient = URLSessionClient()) {
self.client = client
}
func loadWeather() async { ... }
}
In production, the default initializer wires up URLSessionClient. In tests:
let mock = MockHTTPClient()
mock.stubbedData = weatherJSON
let viewModel = WeatherViewModel(client: mock)
No real network calls. No flaky tests. No need for URL interception or third-party mocking frameworks.
The protocol is the seam — the place where production behavior and test behavior meet the same interface. Every meaningful unit test in a well-architected iOS app exists because someone defined a protocol at the right boundary.
Avoiding Protocol Proliferation
One failure mode of POP is creating protocols for every single type — a UserRepositoryProtocol wrapping UserRepository with identical method signatures, providing no architectural value.
A protocol earns its existence when:
- There are two or more meaningful conformances (production + test is the minimum)
- The protocol represents a genuine behavioral contract, not just a type alias
If a protocol has exactly one conformer and you never test through it, it’s indirection without benefit. Delete it.
Part 5: SwiftUI and Protocols — The Architecture at Scale
SwiftUI is the most protocol-saturated framework Apple has shipped. Understanding it well means understanding how protocols compose at scale.
View — The Recursive Protocol
public protocol View {
associatedtype Body: View
@ViewBuilder var body: Self.Body { get }
}
View defines an associated type called Body, which must itself conform to View.
This recursive structure is what enables SwiftUI’s endless composition model: every view’s body is another view, all the way down to primitives like Text and Color, whose Body type is Never.
That’s why:
var body: some View
works.
The some keyword satisfies the associated type requirement using an opaque type, while the compiler resolves the concrete Body at build time by inferring it from the @ViewBuilder closure.
Protocols for Reusable Components
One of the cleanest SwiftUI patterns is protocol-driven component abstraction:
protocol ListItemRepresentable: Identifiable {
var title: String { get }
var subtitle: String? { get }
var iconName: String { get }
}
struct ListItemRow<Item: ListItemRepresentable>: View {
let item: Item
var body: some View {
HStack {
Image(systemName: item.iconName)
VStack(alignment: .leading) {
Text(item.title)
if let subtitle = item.subtitle {
Text(subtitle).foregroundStyle(.secondary)
}
}
}
}
}
ListItemRow works with any type that satisfies ListItemRepresentable. Your User, Message, Notification — all can be rendered by the same component without any changes to the view.
This is composition over inheritance applied directly to UI. No UITableViewCell subclass hierarchy. No configure(with:) casting.
The AnyView Problem — Type Erasure and Its Costs
Here’s a trap that catches nearly every developer new to SwiftUI:
// ❌ This seems convenient
func makeRow(for item: Item) -> AnyView {
if item.isPinned {
return AnyView(PinnedRow(item: item))
} else {
return AnyView(StandardRow(item: item))
}
}
AnyView is SwiftUI's built-in type eraser — it wraps any View in a uniform box so you can return different view types from the same function. It solves the "I need to return different types" problem. It does so at a real cost.
SwiftUI’s diffing engine works by comparing the static type graph of a view hierarchy. When the type tree changes between renders, SwiftUI discards the old subtree and creates a new one — losing animations, state, and view identity in the process. When it stays the same, SwiftUI surgically updates only what changed.
AnyView erases the type information that SwiftUI's diffing engine depends on. Every AnyView is opaque — the diffing engine can't see inside. It treats every re-render as a full replacement. This destroys animations, breaks @State continuity, and degrades performance in lists and complex hierarchies.
The idiomatic solution is to let the type system carry the branching:
// ✅ Correct: type information preserved
@ViewBuilder
func makeRow(for item: Item) -> some View {
if item.isPinned {
PinnedRow(item: item)
} else {
StandardRow(item: item)
}
}
@ViewBuilder uses a result builder under the hood to construct a concrete conditional type — _ConditionalContent<PinnedRow, StandardRow> — that is fully visible to the diffing engine. SwiftUI can track which branch is active and update it surgically.
The lesson: in SwiftUI, static type information is not just a compiler concern — it’s runtime performance data.
Every time you reach for AnyView, you're paying with view identity. Reserve it for the rare cases where type erasure is genuinely required (heterogeneous collections stored in @State, for instance), and prefer @ViewBuilder everywhere else.
SwiftUI’s @Environment and custom EnvironmentKey are the framework's built-in DI system:
struct HTTPClientKey: EnvironmentKey {
static let defaultValue: any HTTPClient = URLSessionClient()
}
extension EnvironmentValues {
var httpClient: any HTTPClient {
get { self[HTTPClientKey.self] }
set { self[HTTPClientKey.self] = newValue }
}
}
// Inject in root
ContentView()
.environment(\.httpClient, MockHTTPClient())
// Consume deep in the tree
struct WeatherView: View {
@Environment(\.httpClient) var client
}
The protocol HTTPClient is what makes this work. Without it, you can't swap implementations through the environment — you'd be tied to a single concrete type. The protocol is invisible infrastructure that makes the whole composition possible.
Part 6: Where Teams Actually Get Burned
Theory is clean. Production rarely is.
These are the failure modes that show up repeatedly in real iOS codebases.
❖ The Protocol Explosion
A team adopts protocol-oriented programming, hears that “protocols are good,” and starts wrapping every type in a protocol:
UserServiceProtocolAnalyticsManagerProtocolThemeProviderProtocol
Each has a single conformer, is never mocked, and adds no meaningful architectural flexibility.
The result:
- more files,
- more indirection,
- more boilerplate,
- and slower iteration.
Every change now requires updating:
- the protocol,
- the implementation,
- and every reference site.
That isn’t protocol-oriented design. It’s bureaucratic abstraction.
A protocol earns its existence when there are at least two meaningful conformances. Until then, prefer the concrete type.
❖ The Generic Compile-Time Explosion
Associated types don’t always scale gracefully.
Once protocols with associated types begin referencing other protocols with associated types, the compiler’s type inference workload can grow dramatically.
In large modules with aggressive PAT usage, teams sometimes see:
- cold build times balloon,
- “type checking took too long” warnings,
- or unexplained compiler slowdowns.
The fix is often architectural:
- replace deeply nested PAT chains with concrete generic parameters,
- or terminate associated-type composition at natural module boundaries.
❖ The Mock-Everything Trap
Protocol-based dependency injection makes mocking easy — sometimes too easy.
Some teams end up mocking everything:
MockStringFormatterMockDateCalculatorMockCurrencyFormatter
At that point, tests stop validating real behavior and start validating mock implementations.
Mock trust boundaries:
- networking,
- databases,
- analytics,
- file systems.
Don’t mock pure functions and value objects simply because the language allows it.
❖ Protocol as Namespace
Protocols are behavioral contracts — not organizational folders.
A Utilities protocol or Helpers protocol usually signals unclear ownership rather than good abstraction.
If a protocol doesn’t describe a meaningful capability, it probably shouldn’t exist.
❖ PAT Complexity Without Payoff
Associated types are powerful, but they’re also genuinely difficult to compose.
If you’ve spent an hour fighting some / any compiler errors without a clear architectural win, it’s worth asking whether a generic struct would solve the problem more cleanly than a generic protocol.
Not every abstraction needs to be protocol-driven.
❖ Ignoring Witness Table Costs in Tight Loops
For most app-layer code, existential overhead is negligible.
But in performance-critical paths — game loops, real-time audio, machine learning inference — dynamic dispatch through witness tables is measurable.
Generic functions like:
func process<R: Repository>(_ repo: R)
use static dispatch and can be significantly faster than protocol existentials in hot execution paths.
The Architecture That Emerges
When you internalize protocols as capability contracts — not type aliases, not inheritance escapes — your architecture changes shape.
Dependencies flow inward through protocol interfaces. Modules depend on abstractions, not implementations. Test doubles are first-class citizens of your type system. SwiftUI views compose over generic protocol constraints instead of concrete model types.
This isn’t a pattern or a framework. It’s what the language is asking you to do. The protocols, the associated types, the some/any distinction — all of it is infrastructure in service of one idea: describe what a thing does, not what a thing is.
Systems built on that idea scale. Systems built on deep class hierarchies fossilize.
If this article clarified the deeper mechanics of Swift protocols, follow for more writing at the intersection of language design and iOS architecture.
메타데이터
- post_id
- c3fa1f9bc2fe
- slug
- protocols-in-swift-the-feature-that-makes-ios-architecture-work-c3fa1f9bc2fe
- url
- https://medium.com/@shashidj206/protocols-in-swift-the-feature-that-makes-ios-architecture-work-c3fa1f9bc2fe
- canonical_url
- https://medium.com/@shashidj206/protocols-in-swift-the-feature-that-makes-ios-architecture-work-c3fa1f9bc2fe
- author_url
- https://medium.com/@shashidj206
- status
- ok
- fetched_at
- 2026-06-09 15:37:30