Protocols, Generics, and Access Boundaries
In my first write-up on Swift Fundamentals, I walked through the core primitives of the language: variables, collections, closures, and the mental model separating value-type struct definitions from reference-type class instances.
At the end of that piece, I noted that structs are Swift’s natural default. They are predictable, safe from unintended side effects, and trivial to reason about in multithreaded systems.
Yet when developers arrive in Swift from traditional object-oriented languages like Java, C#, or C++, their instinct is almost always the same: reach for class hierarchies. When you need multiple distinct domain models to share behavior—say, caching logic, database persistence, or serialization—the instinctive OOP move is to draw an inheritance tree and make everything a subclass of a massive base class.
In Swift, fighting that instinct isn’t just an aesthetic choice. It is the central design shift of the language.
Swift is fundamentally a Protocol-Oriented language. To understand why Swift prefers protocols over class inheritance, syntax cheat sheets aren’t enough. You have to write code where inheritance fails you.
That is why I began building SwiftVault, an in-memory caching and analytics engine. Phase 1 focuses on my learning journey: Protocols, Generics, and Access Control.
In classic object-oriented design, you would solve this by creating an abstract parent class:
// The OOP Trap:
class BaseStorableObject {
let id: UUID
let createdAt: Date
init(id: UUID, createdAt: Date) {
self.id = id
self.createdAt = createdAt
}
}
class Transaction: BaseStorableObject { ... }
This pattern carries severe architectural problems in Swift:
-
Forced Reference Semantics: The moment a model inherits from a base class, it is forced to become a
class. You lose all the benefits of value types: you inherit shared mutable state, potential memory leaks via reference cycles, and heap allocation overhead, whether you want them or not. -
The Fragile Single-Inheritance Tax: Swift does not support multiple inheritance. If
Transactionalready inherits fromBaseStorableObject, it cannot inherit from anything else. -
Rigid Identity Contracts: A base class must mandate exact storage definitions. If
BaseStorableObjectdefineslet id: UUID, every model in your application is permanently chained to UUIDs, even if your server returnsIntIDs for users andStringkeys for configurations.
Protocols as Contracts: The Shift to Protocol-Oriented Design
Swift favors Protocol-Oriented Design because it untangles behavior from storage. Instead of defining what a model is through inheritance, we define what it guarantees through a protocol contract:
import Foundation
protocol Storable: Identifiable, Equatable {
var createdAt: Date { get }
var tags: Set<String> { get }
}
extension Storable {
var ageInSeconds: TimeInterval {
Date().timeIntervalSince(createdAt)
}
func matches(tag: String) -> Bool {
tags.contains(tag.lowercased())
}
}
This small declaration unlocks three architectural advantages:
-
Freedom to Use Structs:
Storabledoes not mandate a class. A lightweight, immutable value-typestructcan conform to it cleanly. -
Protocols as Minimum Contracts (
{ get }): Writingvar createdAt: Date { get }in a protocol does not mean the underlying model must use a computed property or mutablevar. It means: to the outside reader, this property must be readable. A conforming struct satisfies this using a fast, immutableletconstant. -
Horizontal Code Sharing via Protocol Extensions: Before Swift 2.0, protocols were purely abstract interfaces. Today, protocol extensions allow us to provide concrete logic (
ageInSeconds,matches(tag:)) that gets dispatched to any conforming type for free, without single-inheritance bottlenecks.
Now our Transaction model can stay a clean, thread-safe value type:
struct Transaction: Storable {
let id: UUID
let amount: Double
let createdAt: Date
let tags: Set<String>
init(id: UUID = UUID(), amount: Double, createdAt: Date = Date(), tags: Set<String> = []) {
self.id = id
self.amount = amount
self.createdAt = createdAt
self.tags = Set(tags.map { $0.lowercased() })
}
}
Bridging Protocols with Generics
Once your domain models are decoupled from base classes, you need a container to manage them. By pairing protocols with Generics and Access Control, the compiler guarantees end-to-end type safety:
final class Vault<Element: Storable> {
fileprivate var storage: [Element.ID: Element] = [:]
private(set) var totalInsertions: Int = 0
init() {}
func insert(_ item: Element) {
storage[item.id] = item
totalInsertions += 1
}
func retrieve(id: Element.ID) -> Element? {
return storage[id]
}
func findMatching<T: Collection>(tagsToMatch: T) -> [Element] where T.Element == String {
return storage.values.filter { item in
tagsToMatch.allSatisfy { tag in item.matches(tag: tag) }
}
}
}
extension Vault {
var allItemsSortedByAge: [Element] {
storage.values.sorted { $0.createdAt < $1.createdAt }
}
}
Notice the synergy:
-
Dynamic ID Resolution (
Element.ID): BecauseStorableinherits from standard libraryIdentifiable,Element.IDautomatically adapts the internal dictionary keys to match whatever identifier typeElementuses. -
Defensive API Boundaries (
private(set)&fileprivate):private(set)leavestotalInsertionsreadable across the module while reserving write operations strictly toVault. Meanwhile, markingstorageasfileprivateallows extensions in the same file to perform direct queries likeallItemsSortedByAgewhile shielding mutable storage from the outside world. -
Flexible Method Constraints (
where T.Element == String): The caller can pass anArray<String>, aSet<String>, or any custom collection intofindMatchingwithout paying for intermediate type allocations.
Putting Everything to Work
func runPhase1Demo() {
let vault = Vault<Transaction>()
let coffee = Transaction(amount: 4.50, tags: ["Food", "Coffee"])
let laptop = Transaction(amount: 1299.00, tags: ["Tech", "Work", "Coffee"])
vault.insert(coffee)
vault.insert(laptop)
// 1. Asymmetric access control: readable, but immutable from the outside
print("Total insertions: \(vault.totalInsertions)") // Prints: 2
// 2. Default logic derived from the protocol extension
if let fetched = vault.retrieve(id: coffee.id) {
print("Coffee record is \(fetched.ageInSeconds) seconds old.")
}
// 3. Generic query executing across both an Array and a Set
let arrayQuery = vault.findMatching(tagsToMatch: ["coffee", "work"])
let setQuery = vault.findMatching(tagsToMatch: Set(["coffee"]))
print("Matched both 'coffee' & 'work': \(arrayQuery.count)") // Prints: 1
print("Matched 'coffee': \(setQuery.count)") // Prints: 2
}
runPhase1Demo()
By replacing base classes with protocols, we get to keep our domain models as fast, isolated value types. By pairing protocols with generics, we write reusable engines without casting to Any. And by letting the compiler enforce strict access and concurrency boundaries, architectural bugs are caught before the code ever leaves your machine.
Next time, we will build on this foundation using property observers (willSet/didSet) to evict stale entries on capacity changes, deferring expensive work with lazy vars, and adding custom subscripts and operators to give Vault the feel of a native collection.
If you want to pull down the code and experiment with the compiler checks yourself, the complete, runnable source for Phase 1 of SwiftVault is available in this GitHub Gist.
Did I make a mistake? Please considerSend Email With Subject