Derex.dev

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:

  1. 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.

  2. The Fragile Single-Inheritance Tax: Swift does not support multiple inheritance. If Transaction already inherits from BaseStorableObject, it cannot inherit from anything else.

  3. Rigid Identity Contracts: A base class must mandate exact storage definitions. If BaseStorableObject defines let id: UUID, every model in your application is permanently chained to UUIDs, even if your server returns Int IDs for users and String keys 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: Storable does not mandate a class. A lightweight, immutable value-type struct can conform to it cleanly.

  • Protocols as Minimum Contracts ({ get }): Writing var createdAt: Date { get } in a protocol does not mean the underlying model must use a computed property or mutable var. It means: to the outside reader, this property must be readable. A conforming struct satisfies this using a fast, immutable let constant.

  • 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:

  1. Dynamic ID Resolution (Element.ID): Because Storable inherits from standard library Identifiable, Element.ID automatically adapts the internal dictionary keys to match whatever identifier type Element uses.

  2. Defensive API Boundaries (private(set) & fileprivate): private(set) leaves totalInsertions readable across the module while reserving write operations strictly to Vault. Meanwhile, marking storage as fileprivate allows extensions in the same file to perform direct queries like allItemsSortedByAge while shielding mutable storage from the outside world.

  3. Flexible Method Constraints (where T.Element == String): The caller can pass an Array<String>, a Set<String>, or any custom collection into findMatching without 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