SOLID principles in Swift
Five old principles, each shown as the iOS bug it prevents — because SOLID is not theory, it is a list of ways codebases rot.
By the end you will be able to
- State each principle as the concrete failure it prevents in an iOS codebase
- Refactor a violation of each into compliant Swift
- Know when applying a principle is premature — and say why
SOLID is five principles from the object-oriented era. What are they actually for?
S — Single Responsibility
The pain: the 900-line view model. It fetches, caches, formats dates, validates input, tracks analytics, and decides navigation. Every feature touches it, so every merge conflicts in it; every change risks the parts you didn't mean to touch; no one can test "just the validation".
The principle: a type should have one reason to change. Not "do one tiny thing" — change for one kind of reason. Formatting rules change when design changes; caching changes when infrastructure changes. When those live in one type, unrelated forces pull on the same file.
// One type, four reasons to change:
final class CheckoutViewModel {
func loadCart() { } // changes when the API changes
func formatPrice() { } // changes when locale/design changes
func validateAddress() { } // changes when shipping rules change
func trackCheckoutEvent() { } // changes when analytics changes
}
// Four types, one reason each — and the view model becomes a thin coordinator:
struct PriceFormatter { }
struct AddressValidator { }
struct CheckoutTracker { }
final class CheckoutViewModel {
// composes the three, owns only screen state
}
The test for a responsibility split is never line count — it is: can you name the type's job in one sentence with no "and"?
O — Open/Closed
The pain: adding Apple Pay means editing switch paymentMethod in eleven files. Each edit risks the methods that already worked; each was "done" and is now open-heart surgery again.
The principle: open for extension, closed for modification — adding a case shouldn't mean editing working code. Swift's tool for this is the protocol: new behaviour arrives as a new conforming type.
Checkout is closed — finished, tested, untouched — yet the system is open: shipping Apple Pay is one new file. Compare the enum-and-switch version: correct too, but every addition reopens every switch.
The honest counterweight: the enums lesson praised exhaustive switches for making additions loudly visible. Both are right. Use the enum when the set of cases is yours and closed (screen states); use the protocol when the set is growing at the edges (payment providers, export formats, analytics backends). Choosing which force you want — compile-time exhaustiveness or extension-without-modification — is the design decision.
L — Liskov Substitution
The pain: func render(_ view: BaseCell) works for every cell except PromoCell, which crashes unless you call configure() first — so now every call site checks if let promo = view as? PromoCell, and the abstraction lies.
The principle: a subtype must be usable anywhere its supertype is promised, honouring the same contract — no extra preconditions, no surprise traps, no "supported except". If callers must know which concrete type they hold, the hierarchy has failed at its one job.
// The classic violation — Square "is-a" Rectangle, mathematically. Behaviourally, no:
class Rectangle {
var width = 0.0
var height = 0.0 // callers assume: setting height leaves width alone
}
class Square: Rectangle {
override var height: Double {
didSet { width = height } // breaks the assumption every caller holds
}
}
Any code that sets both dimensions on "a Rectangle" silently misbehaves when handed a Square. In Swift the fix is usually to stop inheriting: model Square and Rectangle as structs conforming to a Shape protocol whose contract (area) both can honour fully. Swift's struct-first, protocol-first culture is partly this principle institutionalised — protocols state exactly the contract, and value types can't half-inherit behaviour they then betray.
The working test: could a caller holding the abstraction ever need to know which concrete type it has? Every as? downcast in business logic is the principle filing a complaint.
I — Interface Segregation
The pain: conforming to StoreListener requires implementing fourteen methods; your type cares about one and stubs thirteen with fatalError("unused"). Compile-time noise, runtime landmines.
The principle: no client should be forced to depend on methods it does not use. Prefer several narrow protocols to one wide one — and Swift makes narrow protocols unusually cheap, because one type can adopt several, and & composes them at the point of use:
// Wide — every conformer carries all of it:
protocol StoreObserver {
func priceChanged(); func stockChanged(); func reviewsChanged(); // …
}
// Narrow — each client asks for exactly what it needs:
protocol PriceObserving { func priceChanged() }
protocol StockObserving { func stockChanged() }
struct BadgeUpdater: StockObserving { func stockChanged() { } }
func register(_ observer: PriceObserving & StockObserving) { }
You met the composition idea in the protocols lesson; ISP is why it matters at scale: wide protocols entangle every conformer with every client's needs. This is also the reason Codable is Encodable & Decodable rather than one protocol — half the types in an app only ever travel one direction.
D — Dependency Inversion
The pain: the whole app imports the networking module; changing the JSON client recompiles and retests everything; nothing runs without a server. You met this in the architecture lesson — DI practice (inject dependencies) is the daily habit; inversion is the structural idea behind it.
The principle: high-level policy should not depend on low-level detail — both should depend on an abstraction that the policy owns. Direction is the whole point:
Without inversion: CheckoutModel ──▶ StripeClient (policy depends on detail)
With inversion: CheckoutModel ──▶ PaymentGateway ◀── StripeClient
(protocol, owned by the checkout feature)
CheckoutModel defines the PaymentGateway protocol it needs, in its module; StripeClient conforms from the outside. Swap Stripe for Adyen and the checkout feature — the code that embodies what your app is — does not change or even recompile. Every fake you wrote in the architecture lesson was this principle working: tests are just another low-level detail plugging into the policy's abstraction.
LSP in six lines: the subclass tightens a precondition its parent promised not to have. What does this print?
class Storage {
func save(_ text: String) -> String {
"saved \(text.count) chars"
}
}
final class AuditedStorage: Storage {
override func save(_ text: String) -> String {
if text.isEmpty {
return "REJECTED — audit requires content"
}
return "audited & \(super.save(text))"
}
}
func autosave(drafts: [String], to storage: Storage) {
for draft in drafts {
print(storage.save(draft))
}
}
autosave(drafts: ["hello", ""], to: Storage())
autosave(drafts: ["hello", ""], to: AuditedStorage())A teammate proposes protocols for every type in a 2-screen app "for SOLID". What is the senior answer?
Split by reason-to-change
ReportScreen mixes three responsibilities. Split it into ReportFormatter, ReportMailer and a slim ReportScreen that composes them — output unchanged.
Open for extension
exportAll currently switches on a format enum — every new format edits it. Refactor to an Exporter protocol so adding Markdown touches no existing code, then add it.
Take the last painful change you made (or watched) in any codebase — the one that touched too many files or broke something unrelated. Name which SOLID principle's absence caused the pain, what the compliant structure would have looked like, and — honestly — whether the compliant version would have been worth its cost before the change arrived.
You can now:
- State each principle as the specific failure it prevents
- Refactor the god-object, the growing switch, the lying subtype, the fat protocol, the hard-wired detail
- Argue against premature abstraction with the same vocabulary
Next up: the named architectures — MVVM, Clean, VIPER — which are these five principles in different pre-packaged doses.