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.
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 views full of logic, models that reach for globals by name, and logic that reads the clock
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 force one architecture on you. But one shape has settled out of practice, and it has only three parts:
View — draws the UI from state and passes taps to the model. Makes no decisions.
Model — @Observable. Owns the state and every decision. Imports no SwiftUI.
Dependency — a protocol for anything outside the app: network, disk, clock.
Here are the three parts as code. Read them in that order, because the model is written against the dependency.
// 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: any QuoteFetching
init(fetcher: any 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 is now boring, and boring views are the goal. Every decision that has a right answer sits in WatchlistModel. A test can create that model and hand it a fake: a small stand-in type you write yourself, which conforms to QuoteFetching and returns whatever data the test needs. Then the test checks what the model produced. Nothing has to be drawn on screen.
Dependency injection: the one habit that pays for the rest
One decision in that code pays for everything else. WatchlistModel receives a QuoteFetching value through its initializer. It never builds a network client itself, and it never looks one up by name.
That habit has a name: dependency injection. The type declares what it needs, and whoever creates the type hands it over. The opposite habit is writing NetworkClient.shared.fetchQuotes(...) straight inside the model. There, the model goes and finds the thing itself, by name, at the moment it needs it. This lesson calls that reaching for a global.
Injection buys four things that real projects need:
- Tests without the network. Hand the model a fake that returns fixed data, or one that throws, and check what the model does with it. The test is fast, it gives the same answer every time, and it runs on the build server, which has no account on the real backend.
- Previews without a backend.
#Preview { WatchlistView(model: WatchlistModel(fetcher: FakeQuotes())) }renders instantly and offline, with data you chose: an empty list, a very long symbol name, a negative price. - Swappable infrastructure. The day the backend moves from REST to GraphQL,
WatchlistModelis not edited at all. Only the type that conforms toQuoteFetchingis rewritten. - Coupling you can see. Coupling is how much one type depends on other things. Here the initializer lists all of it. A model that takes six dependencies is telling you, in one line, that it does too much. When a model reaches for globals by name instead, the only way to learn what it touches is to read every line of it.
The code below is that same model, run twice: once against a fetcher that succeeds, and once against a fetcher that throws.
Look at the last two lines of that output. When the fetch throws, the model sets errorMessage to text the screen can show, and leaves portfolioTotal at zero. It does not crash, and it does not display a stale number. Without the protocol, the only way to see that behaviour was to turn on airplane mode and tap around by hand.
Two words you will hear constantly. The happy path is the run where everything works. A sad path is any run where something fails: no network, an expired token, an empty response, a request that takes thirty seconds. Sad paths are where production bugs live, and they are the first thing hand-testing skips. Making them cheap to check is most of what "testable" means.
Time and randomness are dependencies too
The dependency people forget is not the network client. It is the clock. Date() asks the operating system what time it is right now, so any property that calls it gives a different answer depending on when it runs.
// The problem: whether this is true depends on the day the test runs.
struct SaleBanner {
let saleEndsAt: Date
var isVisible: Bool {
Date() < saleEndsAt // reads the device clock, every time
}
}
Here is how that becomes a build failure nobody can explain. Follow the sequence:
- In April, an engineer writes a test. It creates a
SaleBannerwhose sale ends in June, and checks thatisVisibleistrue. The test passes. - The test is committed. It keeps passing on every build for two months.
- June arrives.
Date()now returns a date later thansaleEndsAt, soisVisibleisfalse. - The test fails, on a pull request that changed nothing about banners. Whoever opened that pull request has to work out why, and the real answer is "the calendar moved".
The fix is to pass the clock in, exactly the way the fetcher was passed in:
// The fix: the model is handed a function that returns the current time.
struct SaleBanner {
let saleEndsAt: Date
let now: () -> Date // production passes Date.init
var isVisible: Bool {
now() < saleEndsAt
}
}
A test can now check both cases directly: a time one second before the sale ends, and a time one second after. Both checks run in microseconds, and both still pass in 2030. The same approach works for UUID(), for random shuffles, and for the locale and time zone the device is set to.
The rule behind all of it: anything a type reads from outside itself is a dependency, and dependencies arrive 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 code | What its test looks like | So it lives in |
|---|---|---|
| "the total is the sum of the quote values" | create the model, check a number | the model, as a computed property |
| "a failed refresh shows a retry message" | a fake that throws, check the message | the model, as a method |
| "the total is bold and orange" | you would have to look at the screen | the view |
| "tapping refresh calls the API once" | a fake that counts the calls | the model, plus a fake |
| "HTTP 401 means re-authenticate" | a fake that returns 401 | the type that conforms to the dependency's protocol |
Views keep what is genuinely visual. Everything with a right answer moves to where a test can reach it.
Derived values stay computed rather than stored. portfolioTotal cannot go stale, because there is nothing stored to go stale: it is worked out from quotes every time it is read. That is the same rule as the Observation lesson (U2-02).
BannerModel takes its clock as a dependency, and the code hands it a clock the test controls. 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)A teammate argues: "Injecting protocols everywhere is over-engineering. URLSession.shared works fine." What is the strongest reply?
Take a screen from an app you know well: a checkout, a feed, a settings page. Write down every outside-world thing it touches — network, clock, storage, location, notifications. That list is what its initializer should have taken. Then pick the one whose failure would hurt users most, write the protocol for it, and write the two fakes you would test it with: one that works and one that fails.
You can now:
- Split a screen into a boring view, a testable model and injected dependencies
- Write a protocol seam and check both outcomes with two fakes
- Treat time and randomness as dependencies, not as facts of life
- Use "what would the test look like?" to decide where a piece of code belongs
Next up: SOLID — five named ways a codebase becomes expensive to change, each shown with the production bug it prevents.