App architecture · advanced

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.

14 min read15 min practice0/2 exercises5 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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.

Adding a payment method without touching existing code.

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.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

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())
Check yourself

A teammate proposes protocols for every type in a 2-screen app "for SOLID". What is the senior answer?

Split by reason-to-change

Write the code

ReportScreen mixes three responsibilities. Split it into ReportFormatter, ReportMailer and a slim ReportScreen that composes them — output unchanged.

Solution unlocks after 3 attempts

Open for extension

Write the code

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.

Solution unlocks after 3 attempts
Connect itSaved on this device. Never graded.

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.

Checkpoint

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.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.