OOP in Swift: abstraction, encapsulation, inheritance & polymorphism
A clear mental model of the four pillars, composition over inheritance, and how Swift protocols change the design trade-offs.
Four ideas, different questions
Abstraction asks which capabilities the caller needs; encapsulation protects internal state and its invariants; inheritance reuses or specializes class behavior; polymorphism lets different implementations satisfy one interface. Protocol-oriented design is especially useful in Swift because structs do not support class inheritance.
1. Abstraction
Use a protocol to describe what a service does, not how it performs the work. This makes a use case independent from HTTP, persistence, or payment-provider details.
import Foundation
protocol PaymentProcessing {
func pay(amount: Decimal) async throws
}
struct Checkout {
let payment: any PaymentProcessing
func confirm(total: Decimal) async throws {
try await payment.pay(amount: total)
}
}2. Encapsulation
Protect invariants with access control. private(set) lets callers observe a value while requiring mutations to go through validated methods. A computed balance also avoids duplicated derived state.
struct Cart {
private(set) var items: [String] = []
var itemCount: Int { items.count }
mutating func add(_ item: String) {
guard !item.isEmpty else { return }
items.append(item)
}
}3. Inheritance
Only classes can inherit from other classes in Swift. Override behavior when there is a genuine is-a relationship; avoid deep hierarchies that make behavior difficult to predict. Mark a class final when subclassing is unnecessary.
class Screen {
func title() -> String { "Screen" }
}
final class HomeScreen: Screen {
override func title() -> String { "Home" }
}4. Polymorphism
Callers interact with a protocol while runtime implementations vary. Swift supports this through protocol existentials and generics; inheritance is not required.
import Foundation
struct CardPayment: PaymentProcessing {
func pay(amount: Decimal) async throws { /* Call card gateway */ }
}
struct BankTransfer: PaymentProcessing {
func pay(amount: Decimal) async throws { /* Start transfer */ }
}
func complete(_ payment: any PaymentProcessing) async throws {
try await payment.pay(amount: 10)
}Composition versus inheritance
Inheritance means HomeScreen IS A Screen: behavior is coupled to a base class and changes to that parent can affect descendants. Composition means an AccountsViewModel HAS AN AccountsRepository and a formatter; it delegates work to collaborators. When the repository changes from network to mock, the ViewModel keeps the same type. Composition is therefore often easier to test and evolve. Prefer it for services and cross-cutting behaviors, but inheritance is still valid for stable class specializations.
struct Account { let name: String }
protocol AccountsRepository {
func load() async throws -> [Account]
}
struct AccountsViewModel<Repository: AccountsRepository> {
let repository: Repository
func names() async throws -> [String] {
try await repository.load().map(\.name)
}
}
struct PreviewRepository: AccountsRepository {
func load() async throws -> [Account] {
[Account(name: "Checking")]
}
}| Approach | Good fit | Main trade-off |
|---|---|---|
| Protocol + composition | Replaceable services, independent collaborators | More types and wiring |
| Class inheritance | Genuine stable is-a specialization | Tight parent–child coupling |
| Generic composition | Fixed collaborator type, compile-time specialization | More type parameters |
| any Protocol | Runtime choice between implementations | Existential indirection and lost concrete identity |
Interview traps
A protocol is not automatically an abstraction of good quality; avoid pointless one-method protocols. Encapsulation is not just marking fields private: it also means protecting invariants. Composition does not eliminate all coupling—it moves it into explicit dependencies. Do not confuse protocol conformance with class inheritance.
Personal study notes. Check details against your target Swift/iOS version and official documentation.