App architecture · advanced
SOLID principles in Swift
Five principles. Each one is given in its author's own words, translated into Swift, explained in plain language, then shown as broken code followed by fixed code. SOLID is not theory. It is a list of five specific ways a codebase becomes expensive to change.
By the end you will be able to
- State each principle the way its author states it, and translate it into Swift terms
- Recognise each principle's violation in production-shaped code, not textbook toys
- 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?
Each of the five sections below has the same five parts, so you can review the lesson at a glance:
- The principle states — the one-line statement, in the words of the person who wrote it. This is what an interviewer is listening for.
- In Swift terms — the same idea in this language's vocabulary.
- In plain words — what it actually means, with the jargon unpacked.
- The pain — the production bug you get when you ignore it.
- Examples — two or three, each showing the broken code first and the fixed code second.
The examples are checkouts, invoices, feeds and widgets. No shapes, no animals, no vehicles: nobody has ever been paged at 2 a.m. because a Square inherited from a Rectangle.
S — Single Responsibility
The principle states: "A class should have only one reason to change." — Robert C. Martin. In his later book Clean Architecture he sharpens the same idea into: "a module should be responsible to one, and only one, actor."
In Swift terms: a type should answer to exactly one force — one source of change — in the real world.
In plain words: "responsibility" here does not mean "does one small thing". It means "answers to one kind of change".
Ask this about any type you write: who can walk up and ask for this file to be changed? A designer wants a new look. The backend team renames a field. Finance introduces a tax rule. Marketing switches analytics vendor. Those are four different people, working to four different schedules, in four different parts of the company.
Martin calls those people actors. Be careful with that word here: it has nothing to do with Swift's actor keyword from C2-01. In this principle it just means "a person or team who can ask you for a change".
When one type answers to several of those people, two things go wrong. First, a finance change can break the design code sitting beside it: the two live in the same file and the same test target, and they share the same stored properties. Second, all four teams edit that file in the same week, so their pull requests keep colliding.
The pain: the 900-line view model. It fetches data, caches it, formats dates, validates input, sends analytics, and decides which screen comes next. Every feature has to touch it, so almost every merge produces a conflict in it. Every change can break a part you were not thinking about. And you cannot test the address validation on its own, because creating the view model means creating everything it holds: a network client, a cache, an analytics client.
Example 1 — the checkout screen four teams edit. The problem. Count the reasons to change, not the lines:
// One type, four reasons to change — four teams queuing for one file:
final class CheckoutViewModel {
func loadCart() { } // changes when the API changes (backend team)
func formatPrice() { } // changes when locale/design changes (design)
func validateAddress() { } // changes when shipping rules change (logistics)
func trackCheckoutEvent() { } // changes when analytics changes (marketing)
}
The fix is four types with one reason to change each. The view model keeps only the job that is genuinely its own: holding what the screen currently shows.
struct PriceFormatter { } // design's file
struct AddressValidator { } // logistics' file
struct CheckoutTracker { } // marketing's file
final class CheckoutViewModel { // composes the three, owns only screen state
private let formatter: PriceFormatter
private let validator: AddressValidator
private let tracker: CheckoutTracker
init(formatter: PriceFormatter,
validator: AddressValidator,
tracker: CheckoutTracker) {
self.formatter = formatter
self.validator = validator
self.tracker = tracker
}
}
That init is the dependency-injection habit from D1-01: a class gets no memberwise initialiser for free, so you write one and the dependencies arrive from outside.
Now a change to the shipping rules touches AddressValidator and nothing else. And you can test AddressValidator with a handful of plain assertions, because it needs no screen, no network and no cart.
Example 2 — the invoice exporter that quietly changes financial state. The problem is that this type does three jobs, and the third one has no business being here:
final class InvoiceExporter {
func export(_ invoice: Invoice) {
let pdf = renderPDF(invoice) // changes when the template changes
upload(pdf) // changes when storage moves providers
markPaid(invoice) // changes when FINANCE RULES change
}
}
Here is how that third line becomes a real production incident. Follow the sequence:
- The company moves its file storage to a new provider.
- An engineer on the infrastructure team edits
uploadto add a retry. It is a storage change, so nobody thinks to ask finance to review it. - The retry has a bug. For some invoices every attempt fails, and
uploadeventually gives up. markPaid(invoice)runs anyway. It is the next line in the function, and nothing stops it.
The result: the database says the invoice is paid, but the PDF that proves it was never uploaded anywhere. An infrastructure change altered financial state, because financial state was written inside a function that infrastructure engineers edit.
The fix gives each of the three jobs its own type, so each type has exactly one group of people who can ask it to change:
InvoicePDFRendererdraws the document. It changes when the template changes.InvoiceArchiveuploads the file. It changes when the storage provider changes.SettleInvoicedecides when an invoice counts as paid. It changes when finance rules change.
Splitting them also fixes the order. In the old code, markPaid ran second because it happened to be the next line. In the new code it can only run after archive.store has returned without throwing:
struct InvoicePDFRenderer { // changes with the template
func render(_ invoice: Invoice) -> PDF { /* layout only */ }
}
struct InvoiceArchive { // changes with the storage provider
func store(_ pdf: PDF) throws { /* upload only */ }
}
struct SettleInvoice { // changes with finance rules — and owns them
let renderer: InvoicePDFRenderer
let archive: InvoiceArchive
func execute(_ invoice: Invoice) throws -> Invoice {
try archive.store(renderer.render(invoice))
return invoice.markedPaid() // state changes only after storage confirms
}
}
If the upload fails now, try throws and the last line never runs. The finance rule lives in the one file finance owns. An engineer swapping the storage provider edits InvoiceArchive and never opens SettleInvoice, so the incident above cannot repeat. D1-05 has a name for a type shaped like SettleInvoice: a use case.
Example 3 — the AppDelegate that every team adds code to. Every iOS codebase has one. The problem:
func application(_ app: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
configurePushRegistration() // platform team
registerDeepLinkRoutes() // every feature team
migrateDatabaseIfNeeded() // data team
configureAppearance() // design
startAnalyticsSDK() // marketing
return true // the file that breaks on every SDK update
}
Every team edits this one function, because it is "where app things go". So every SDK update, every new feature's deep link and every database migration all land in the same place. When the app crashes on launch, five teams have to work out which of them caused it.
The fix is the principle applied literally. Each startup job becomes its own small type, and the delegate is reduced to running them in order:
protocol LaunchService { func start() }
struct PushRegistration: LaunchService { func start() { /* one concern */ } }
struct DeepLinkRoutes: LaunchService { func start() { /* one concern */ } }
struct DatabaseMigration: LaunchService { func start() { /* one concern */ } }
struct AppearanceSetup: LaunchService { func start() { /* one concern */ } }
struct AnalyticsStartup: LaunchService { func start() { /* one concern */ } }
let launchServices: [any LaunchService] = [
PushRegistration(), DeepLinkRoutes(), DatabaseMigration(),
AppearanceSetup(), AnalyticsStartup(),
]
func application(_ app: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
for service in launchServices { service.start() }
return true
}
Notice what the test for "one responsibility" is not. It is not line count: a 400-line date formatter can be perfectly fine, because only one force ever changes it. The test is this — can you describe the type's job in one sentence, without using the word "and"?
O — Open/Closed
The principle states: "Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification."
Quote that sentence carefully, because its authorship is commonly got wrong. Bertrand Meyer coined the principle and its name in 1988, but those exact words are Robert C. Martin's, written in 1996 as a deliberate paraphrase of Meyer — Martin introduces them with "to paraphrase him". Meyer's own 1988 wording defines the two adjectives separately: a module is open if it can still be extended, and closed if it is finished and available for other modules to use. Credit the idea to Meyer and the famous sentence to Martin, and you are right on both counts.
In Swift terms: adding new behaviour should mean adding a new type that conforms to a protocol — not editing code that already works.
In plain words: code that has shipped and survived a few months in production is valuable precisely because it is boring. It works, it has tests, and nobody is afraid of it. Every time you open it up to add one more case, you put that back at risk, and you have to re-test all of it.
So decide in advance where the code is likely to grow: new payment methods, new export formats, new analytics vendors. Put a protocol at each of those spots. Then tomorrow's addition is a new file conforming to that protocol, and the code that already works is never reopened.
Such a spot has a name: a seam. A seam is a place planned ahead of time, so new behaviour can be added without editing what is already there. The term is Michael Feathers' — the same person who coined "SOLID" — from Working Effectively with Legacy Code. In Swift, a seam is almost always a protocol.
The pain: adding Apple Pay means finding and editing switch paymentMethod in eleven files. Each of those edits can break a payment method that already worked, so card payments and gift cards have to be tested again — even though nothing about them was supposed to change.
Example 1 — payment methods behind a protocol. The problem is a switch statement that every new payment method has to reopen, in this file and ten others like it:
func pay(_ amount: Double, using method: PaymentKind) {
switch method {
case .card: chargeCard(amount)
case .giftCard: redeemGiftCard(amount)
// Apple Pay? Edit here — and in the receipt formatter, and the refund
// flow, and the settings screen listing methods…
}
}
Now the fix. One piece of syntax first: any PaymentMethod means "a value whose exact type I do not know at compile time, but which conforms to PaymentMethod". Swift wants that any spelled out so the cost is visible in the code.
Read Checkout at the bottom of this example carefully: it was written before gift cards existed, and adding them did not change a single line of it.
That is the principle's two words in one screen of code. Checkout is closed: finished, tested, never edited again. The system is open: shipping Apple Pay means adding one new file. The switch version works too, but every addition reopens every switch.
Example 2 — a second analytics vendor. Marketing signs one, and the app now has to send events to both. The problem is that the choice between vendors spreads into every screen that logs anything:
func logCheckoutTapped() {
amplitude.track("checkout_tapped")
if FeatureFlags.useFirebase {
firebase.log("checkout_tapped")
}
}
// A third vendor means another branch — in every logging call site in the app.
The fix is one protocol, after which the screens no longer know how many vendors exist:
protocol AnalyticsSink {
func record(_ event: String)
}
struct AmplitudeSink: AnalyticsSink { func record(_ event: String) { /* SDK call */ } }
struct FirebaseSink: AnalyticsSink { func record(_ event: String) { /* SDK call */ } }
struct ConsoleSink: AnalyticsSink { func record(_ event: String) { print(event) } }
final class Analytics {
private let sinks: [any AnalyticsSink]
init(sinks: [any AnalyticsSink]) { self.sinks = sinks }
func record(_ event: String) {
for sink in sinks { sink.record(event) } // written once, never edited again
}
}
A third vendor is one more type conforming to AnalyticsSink. Debug builds pass in ConsoleSink, so events print to the console. Tests pass in a fake — a small stand-in type you write yourself — that stores events in an array so you can assert on them. None of that reopens a single screen.
Example 3 — feed card kinds. A feed shows transaction cards, promo cards and insight cards. The problem is that the card's kind is switched on in three separate places:
switch card.kind { // in the row builder…
case .transaction: return TransactionCell(card)
case .promo: return PromoCell(card)
case .insight: return InsightCell(card)
}
// …and the same switch again in the height estimator, and again in the
// tap-analytics mapper. New card kind = three edits; forget one and
// production shows the placeholder cell.
Those three switches are really three questions the feed asks about every card: which view do I build, how tall am I, and what event does a tap on me send? The fix is to let each kind of card answer all three itself. A new kind of card then becomes one new file, and the rest of the feed code uses it with no edits at all:
protocol CardRendering {
func makeView() -> CardView
var estimatedHeight: Double { get }
var tapEvent: String { get }
}
When the enum is the better choice. The enums lesson (S2-03) praised exhaustive switches, because when you add a case the compiler lists every place that has to handle it. That is the opposite guarantee from this principle, and both are genuinely useful.
Use the enum when the set of cases is yours and closed — the states a screen can be in, for instance. Use the protocol when new cases keep arriving from outside your control: payment providers, export formats, analytics backends. The design decision is choosing which guarantee you want — the compiler's checklist of everything to update, or the ability to add without editing.
L — Liskov Substitution
The principle states: "Subtypes must be substitutable for their base types." — Robert C. Martin's short form. His longer one, written out in full in 1996: "functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it."
Both compress Barbara Liskov's 1987 idea: if you can put an object of the subtype anywhere the supertype is expected, and no program using it can tell the difference, then it is a genuine subtype. (The stricter version people sometimes quote — that every provable property of the supertype must hold for the subtype — came later, from Liskov and Jeannette Wing in 1994.)
In Swift terms: code written to use a supertype must keep working, unchanged and unsurprised, when you hand it any subtype.
In plain words: a superclass or protocol is a promise about behaviour — its contract. BaseCell promises "hand me an item and I will display it". Every subtype inherits that promise, and has to keep all of it.
A subtype may do more than the promise. It may never do less. In practice there are three ways to break it:
- Demand more than the parent did. The parent works as soon as you have it. The subtype crashes unless you call some setup method first.
- Deliver less than the parent did. The parent saves to disk. The subtype throws, or silently does nothing.
- Behave differently behind the same method name. Same signature, same return type, quietly different results.
There is one reliable symptom for all three. If the calling code has to check which concrete type it is actually holding, the abstraction has failed at the only job it had.
The pain: func render(_ cell: BaseCell) works for every cell except PromoCell, which crashes unless somebody remembered to call configure() first. So every call site grows an if let promo = cell as? PromoCell check. The class hierarchy no longer tells the truth, and every caller needs its own special case.
Example 1 — the cell with a hidden extra step. The problem is a requirement that the parent's contract never mentioned:
class BaseCell {
func bind(_ item: FeedItem) { /* every cell renders its item */ }
}
final class PromoCell: BaseCell {
private var campaign: Campaign?
func configure(with campaign: Campaign) { self.campaign = campaign }
override func bind(_ item: FeedItem) {
render(campaign!) // crashes unless configure(with:) ran first —
} // a requirement no other cell has
}
The list code was written to use the promise BaseCell made: give me an item, I will display it. PromoCell quietly added a second step. Nothing in the type system records that, so the only way anyone finds out is a crash report.
The fix is to make bind self-sufficient again. Everything the cell needs arrives with the item, so there is no second step to forget:
final class PromoCell: BaseCell {
override func bind(_ item: FeedItem) {
guard let campaign = item.campaign else { return showFallback() }
render(campaign) // no second step to forget — same contract as
} // every other cell, kept in full
}
Example 2 — the store that supports everything except saving. The problem here is the second failure mode: delivering less than the parent did.
class UserStore {
func load() -> User? { /* read from disk */ nil }
func save(_ user: User) { /* write to disk */ }
}
final class ReadOnlyDemoStore: UserStore {
override func save(_ user: User) {
assertionFailure("demo accounts can't save") // "supported, except…"
}
}
Demo mode ships. Autosave calls save on a timer, exactly as it does for every other user. In debug builds the app crashes; in release builds the write silently disappears. Both bug reports exist, and they look like completely different bugs.
The honest description is that ReadOnlyDemoStore is not a UserStore. It is half of one. So the fix is to stop inheriting and split the contract in two. The rule that used to live in a comment — this one cannot save — becomes something the compiler checks:
protocol UserReading { func load() -> User? }
protocol UserWriting { func save(_ user: User) }
struct DiskUserStore: UserReading, UserWriting { /* the full store, renamed */ }
struct DemoUserStore: UserReading { /* was ReadOnlyDemoStore: only ever reads */ }
// Autosave asks for exactly what it needs — a demo store can't even be passed in:
func autosave(_ user: User, to store: any UserWriting) {
store.save(user)
}
Notice what the fix actually was: we split one wide contract into two narrow ones. That is really the next principle, I (Interface Segregation), showing up here. L and I turn up together often, because a type that cannot keep the whole promise is usually being asked to promise too much.
Example 3 — the quiet contract change. The most realistic version of this violation does not crash at all, which is what makes it expensive. Predict what this prints before you run it:
One screen of code. The subclass refuses an input that the parent accepts. 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())Swift prefers structs and protocols to deep class hierarchies, and that preference is partly this principle built into the language. A protocol states its contract far more narrowly than a base class does, and a struct cannot inherit half of somebody's behaviour and then contradict the other half.
Swift is not immune, though. A method added in a protocol extension but not declared as a requirement of the protocol is dispatched statically: a conforming type can define its own version and still see the extension's version called through the protocol. That is this same quiet contract change, in Swift's own idiom — S2-05 works through it.
The working test: could a caller holding the abstraction ever need to know which concrete type it has? Every as? in business logic — every place you cast an abstraction back down to a concrete type — is a sign this principle has been broken somewhere upstream.
I — Interface Segregation
The principle states: "Clients should not be forced to depend upon interfaces that they do not use." — Robert C. Martin, who arrived at it while untangling a Xerox printing system in which one enormous Job class was depended on by every other part of the codebase.
Two words in that sentence are worth pinning down. Interface is the word other languages use for what Swift calls a protocol. A client is any code that uses a protocol.
In Swift terms: several narrow protocols beat one wide one. No conforming type should have to implement methods that only other callers need.
In plain words: every method you add to a protocol gets paid for three times. Every type that conforms has to implement it. Every fake you write for tests has to stub it — write an empty version purely to satisfy the compiler. And every piece of code that depends on the protocol is tied to methods it will never call, which exist for somebody else entirely.
Swift makes narrow protocols cheap, which is why this principle costs so little here. One type can conform to as many protocols as it likes, and where a caller genuinely needs two of them, & joins them at the point of use: PriceObserving & StockObserving.
The pain: conforming to StoreObserver requires implementing fourteen methods. Your type cares about one of them, so the other thirteen get stubbed with fatalError("unused"). Six months later someone ships promotions, the store starts calling promotionStarted() on every observer, and the app dies inside a type that has nothing to do with promotions.
Example 1 — the wide observer. The problem is one protocol carrying every client's needs, which means every conforming type carries them too:
protocol StoreObserver {
func priceChanged()
func stockChanged()
func reviewsChanged()
func promotionStarted()
// …ten more
}
// The badge updater cares about stock — but must carry everything:
struct BadgeUpdater: StoreObserver {
func stockChanged() { /* the one real method */ }
func priceChanged() { fatalError("unused") }
func reviewsChanged() { fatalError("unused") }
func promotionStarted() { fatalError("unused") }
}
The fix is to let each client ask for exactly what it needs. A type that genuinely needs two things conforms to two protocols, and callers compose them with &:
protocol PriceObserving { func priceChanged() }
protocol StockObserving { func stockChanged() }
// Needs one thing, so it conforms to one protocol — nothing to stub:
struct BadgeUpdater: StockObserving {
func stockChanged() { /* just the job */ }
}
// Genuinely needs both, so it says so:
struct ProductHeader: PriceObserving, StockObserving {
func priceChanged() { }
func stockChanged() { }
}
func register(_ observer: any PriceObserving & StockObserving) { }
register(ProductHeader())
You met protocol composition in S2-04. This principle is why it matters once a codebase is large: one wide protocol ties every conforming type to every client's requirements.
Example 2 — the widget that only reads. Ledger's balance widget (D1-06) needs one thing from the data layer: read the cached balance. The problem is that the data layer offers only one wide protocol:
protocol DataStore {
func fetch(_ id: RecordID) -> Record?
func save(_ record: Record)
func delete(_ id: RecordID)
func migrate()
func exportBackup() -> Data
}
This costs the widget twice. Its test fake has to stub five methods in order to test one. And the widget target compiles against migrate() and exportBackup() — code it should never run. A widget runs in an app extension with a memory budget of a few tens of megabytes and a few hundred milliseconds to produce a view, so a database migration started from a widget is likely to be killed part-way, and it would be racing the main app for the same store. Nothing in this protocol discourages anyone from calling it.
The fix is to define protocols around what each client needs, rather than around what the layer happens to contain:
protocol RecordReading {
func fetch(_ id: RecordID) -> Record?
}
protocol RecordWriting {
func save(_ record: Record)
func delete(_ id: RecordID)
}
// The widget sees only what it can use; its fake is one method:
struct WidgetBalanceProvider {
let store: any RecordReading
}
Example 3 — Apple applied this to Codable. Codable is not one protocol. It is a type alias for Decodable & Encodable, and that split exists because half the types in a real app only ever travel in one direction:
struct TapEvent: Encodable { // leaves the app, never parsed back
let name: String
}
struct RemoteConfig: Decodable { // arrives from the server, never sent up
let flags: [String: Bool]
}
If the standard library had shipped one combined protocol instead, conforming an outbound analytics event would force you to write a decoder that nothing will ever call. That is dead code the compiler demands you write. Splitting the protocol means you only pay for the direction you actually use.
D — Dependency Inversion
The principle states: "A. High-level modules should not depend upon low-level modules. Both should depend upon abstractions. B. Abstractions should not depend upon details. Details should depend upon abstractions." — Robert C. Martin, who elsewhere compresses it to a two-line heading: "Depend upon Abstractions. Do not depend upon concretions."
In Swift terms: the code holding your business rules declares the protocol it needs, and the code that talks to the outside world conforms to that protocol. Never the other way round.
In plain words: Martin's statement leans on four words, so start with those.
High-level means the rules that make your app what it is. An invoice becomes paid when payment clears. A session expires after thirty minutes of inactivity. Those rules would still be true if you rewrote the app in another language next year.
Low-level means whatever you happen to be using this year to carry those rules out: Stripe, URLSession, Core Data, the device's clock. These get replaced, and you do not control most of them.
An abstraction is the protocol between the two. A detail — Martin also says concretion — is the concrete type behind that protocol, such as StripeClient. Later sections here also say policy for the high-level rules; that is Martin's word for exactly what you just read.
The natural way to write this is for the high-level code to import the low-level thing directly. That is the mistake. Your business rules then cannot be compiled, read or tested without the whole SDK attached, and replacing the SDK means editing the rules.
Inversion means putting a protocol between them, and being deliberate about who owns that protocol. The checkout code declares PaymentGateway, written in checkout's own vocabulary — charge(_ amount: Money), not createPaymentIntent. StripeClient then conforms to it. The dependency arrow now runs from Stripe towards checkout, the reverse of where it ran before. That reversal is the "inversion" in the name.
This kind of protocol has a name: a port. Two things make it one — the code that needs the service declares it, and it is written in that code's own words. The type that conforms to a port is an adapter. The pair of terms comes from the "ports and adapters" architecture, also called hexagonal architecture, and D1-05 builds its whole domain layer out of ports.
You already met the habit of passing dependencies in through init in D1-01. This principle is the structural half of the same idea: injection is how the dependency is handed over, inversion is who decides the shape of the thing being handed over.
The pain: every module in the app imports the networking module. Changing the JSON client recompiles and retests the entire app. Nothing runs without a server, so there are no fast tests.
Example 1 — checkout owns its gateway. The problem is business rules importing an SDK:
import StripeSDK
final class CheckoutModel {
private let stripe = StripeClient()
func pay(_ amount: Money) {
stripe.charge(amount)
}
}
// Checkout now recompiles with every Stripe SDK update.
// It cannot run at all without Stripe credentials — not even in a unit test.
The fix: checkout declares the protocol, and Stripe conforms to it from the outside.
// In the Checkout module — named in checkout's vocabulary:
protocol PaymentGateway {
func charge(_ amount: Money) async throws
}
final class CheckoutModel {
private let gateway: any PaymentGateway
init(gateway: any PaymentGateway) { self.gateway = gateway }
}
// In the Payments module, pointing INWARD:
// extension StripeClient: PaymentGateway { … }
The direction of the arrows is the entire point:
Without inversion: CheckoutModel ──▶ StripeClient
the business rules import the SDK directly
With inversion: CheckoutModel ──▶ PaymentGateway ◀── StripeClient
both point at the protocol, and checkout owns it
Read the second line as: nothing flows from Stripe into checkout any more. Moving to another payment provider — Adyen, say — is three steps. Write AdyenClient. Add extension AdyenClient: PaymentGateway. Change the single line where the app builds its CheckoutModel. CheckoutModel itself is not edited, and does not even need to recompile.
Example 2 — the clock is a dependency too. The low-level detail people miss most often is time. The problem is expiry logic reading the system clock directly:
struct SessionPolicy {
func isExpired(startedAt: Date) -> Bool {
Date().timeIntervalSince(startedAt) > 1800
// testable only by waiting 30 minutes — or by the 2 a.m. bug report
}
}
Date() asks the operating system what time it is, which makes it exactly as much of an external dependency as Stripe. You cannot test "expires after thirty minutes" without waiting thirty minutes, and you certainly cannot test what happens across a daylight-saving change.
The fix is for the policy to declare its own protocol for reading the time:
protocol DateProviding { // owned by the policy that needs the time
var now: Date { get }
}
struct SessionPolicy {
let dates: any DateProviding
let lifetime: TimeInterval
func isExpired(startedAt: Date) -> Bool {
dates.now.timeIntervalSince(startedAt) > lifetime
}
}
The obvious name for that protocol would be Clock, but the standard library already has one — the protocol Task.sleep uses — and it measures elapsed time rather than telling you today's date. Naming yours DateProviding avoids the collision.
Production passes in a small wrapper around Date(). Tests pass in a type returning a date they choose, so a single fast test can check 29 minutes 59 seconds (not expired) and 30 minutes 1 second (expired). The same move works for UserDefaults, the keychain and random-number generation. Anything the operating system hands you is a low-level detail, and code that declares its own protocol can be tested without ever touching the real thing.
Example 3 — every test fake you have ever written was this principle in action. Look at three you already know: the fake transports from the networking lesson (D2-01) — HealthyServer, BrokenServer and LyingServer — ScriptedServer from D1-04, and the injected date provider above. Each one is a low-level detail conforming to a protocol that the business rules declared. That is all a test fake is.
This is why testability is not a separate virtue you add later. Teams that skip inversion do not merely end up with slow tests. They end up with business rules that cannot be created at all without a real server, a real database and a real clock attached. That is how "we can't unit test our business logic" stops being a complaint and becomes a permanent fact about a company.
A teammate proposes protocols for every type in a 2-screen app "for SOLID". What is the senior answer?
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 in its author's words — including who really wrote the Open/Closed sentence — translate it into Swift, and explain it in plain language
- Recognise the production shape of each violation: the type four teams edit, the switch that keeps reopening, the subtype with a hidden extra step, the protocol full of other people's methods, and the business rule wired directly to an SDK
- Refactor each one from broken code to fixed code — and argue against premature abstraction using the same vocabulary
Next up: the named architectures — MVVM, Clean and VIPER — which combine these five principles in different fixed recipes.