App architecture · advanced

App architecture that survives growth

Where logic should live, why "it works" is not the bar, and dependency injection as the one habit that keeps a codebase testable.

13 min read15 min practice0/2 exercises4 recall cards

By the end you will be able to

  • Split a screen into view, model and dependencies, and say what belongs where
  • Inject dependencies through protocols so logic is testable without the network
  • Recognise the failure modes — massive views, singleton reach-ins, untestable time
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

A view's button handler formats a date, filters an array, computes a total, and decides which error message to show. The app works and ships. What is actually wrong?

The shape that works

SwiftUI does not impose an architecture, but one shape has settled out of practice, and it is small:

View        — describes UI from state; forwards intents. No decisions.
Model       — @Observable; owns state and logic. No SwiftUI imports.
Dependency  — protocol for anything that touches the outside world.
// The dependency: what the model NEEDS, not how it is done.
protocol QuoteFetching {
    func fetchQuotes(for symbols: [String]) async throws -> [String: Double]
}

// The model: all the decisions, none of the UI.
@Observable
final class WatchlistModel {
    private(set) var quotes: [String: Double] = [:]
    private(set) var errorMessage: String?
    var symbols = ["AAPL", "MSFT"]

    private let fetcher: QuoteFetching

    init(fetcher: QuoteFetching) {
        self.fetcher = fetcher
    }

    var portfolioTotal: Double {
        quotes.values.reduce(0, +)
    }

    func refresh() async {
        do {
            quotes = try await fetcher.fetchQuotes(for: symbols)
            errorMessage = nil
        } catch {
            errorMessage = "Couldn't load quotes. Pull to retry."
        }
    }
}

// The view: a rendering of the model.
struct WatchlistView: View {
    let model: WatchlistModel

    var body: some View {
        List(model.symbols, id: \.self) { symbol in
            Text("\(symbol): \(model.quotes[symbol] ?? 0)")
        }
        .refreshable { await model.refresh() }
    }
}

The view has become boring, and boring views are the goal: every interesting decision now lives in a type that can be constructed in a test, handed a fake fetcher, and interrogated.

Dependency injection is the load-bearing wall

The single decision above that pays for everything else: the model takes QuoteFetching — a protocol — through its initializer, instead of reaching for a concrete network client.

That one habit buys four things real projects need:

  1. Tests without the network. Hand it a fake that returns fixed data — or throws — and assert on behaviour. Fast, deterministic, runs on CI.
  2. Previews without a backend. #Preview { WatchlistView(model: WatchlistModel(fetcher: FakeQuotes())) } renders instantly, offline, with data you chose to show the layout's edge cases.
  3. Swappable infrastructure. The day you move from REST to GraphQL, WatchlistModel does not change — only the conforming type does.
  4. Visible coupling. The initializer lists what the model touches. A model that takes six dependencies is telling you it does too much — a smell you can see, versus singleton reach-ins you have to hunt for.
The same model, exercised with a fake — this is what a unit test does.

Notice what the failing case verifies: the error path produces a user-facing message and a safe total — behaviour you would otherwise only discover by turning on airplane mode and tapping around. That is what "testable" means in practice: the sad paths become cheap to check, and sad paths are where production bugs live.

Time and randomness are dependencies too

The reach-ins people forget are not network clients — they are Date() and .random():

// Untestable: "is the sale banner showing?" depends on when the test runs.
var showsSaleBanner: Bool {
    Date() < saleEndDate
}

// Testable: the clock is injected.
var showsSaleBanner: Bool {
    now() < saleEndDate
}
// where the model takes `now: () -> Date` — and production passes `Date.init`.

A function that takes its clock can be tested at one minute before the sale ends and one minute after, forever, deterministically. The same goes for UUID() generation, random shuffles, and locale. The rule generalises: anything the type reads from the outside world is a dependency, and dependencies come in through the initializer.

What goes where — the sorting rule

When you cannot decide where code belongs, ask "what would a test for this look like?"

The codeTest looks likeSo it lives in
"total is the sum of quote values"construct model, assert numberthe model — computed property
"failed refresh shows a retry message"fake throws, assert messagethe model — method
"the total is bold and orange"you'd have to look at itthe view
"tapping refresh calls the API once"fake counts callsmodel + fake
"HTTP 401 means re-authenticate"fake transport returns 401the dependency's conforming type

Views keep what is genuinely visual. Everything with a right answer moves somewhere a test can reach it. And derived values stay computed — portfolioTotal cannot be stale because it is not stored, which you will recognise from the Observation lesson.

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

The model takes its clock as a dependency. What does this print?

struct BannerModel {
    let saleEndsAt: Int
    let now: () -> Int

    var showsBanner: Bool {
        now() < saleEndsAt
    }

    var message: String {
        if showsBanner {
            return "Sale ends soon!"
        }
        return "Sale over"
    }
}

// "Production": the real clock would be injected here.
var fakeTime = 100
let model = BannerModel(saleEndsAt: 120, now: { fakeTime })

print(model.message)

fakeTime = 130
print(model.message)
Check yourself

A teammate argues: "Injecting protocols everywhere is enterprise ceremony — URLSession.shared works fine." Which is the strongest counter?

Extract the logic from the view

Write the code

This "view" (modelled as a struct with a render method) computes everything inline. Move the logic into a testable CartModel so the view only renders, and the assertions at the bottom pass against the model directly.

Solution unlocks after 3 attempts

Inject the failure

Write the code

SyncModel reaches into a hard-coded uploader, so its failure handling cannot be exercised. Introduce an Uploading protocol, inject it, and demonstrate both outcomes with two fakes.

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

Take a screen from an app you know well — a checkout, a feed, a settings page. List its dependencies the initializer-honest way: every outside-world thing it touches (network, clock, storage, location, …). Then write the protocol for the one dependency whose failure path most needs testing, and the two fakes you would exercise it with.

Checkpoint

You can now:

  • Split a screen into boring view, testable model and injected dependencies
  • Write a protocol seam and exercise both outcomes with fakes
  • Treat time and randomness as dependencies
  • Use "what would the test look like?" to decide where code lives

Next up: networking — URLSession, real-world Codable, and the failure paths that deserve those tests.

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.