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 the failure modes — massive views, singleton reach-ins, untestable time
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:
- Tests without the network. Hand it a fake that returns fixed data — or throws — and assert on behaviour. Fast, deterministic, runs on CI.
- Previews without a backend.
#Preview { WatchlistView(model: WatchlistModel(fetcher: FakeQuotes())) }renders instantly, offline, with data you chose to show the layout's edge cases. - Swappable infrastructure. The day you move from REST to GraphQL,
WatchlistModeldoes not change — only the conforming type does. - 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.
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 code | Test looks like | So it lives in |
|---|---|---|
| "total is the sum of quote values" | construct model, assert number | the model — computed property |
| "failed refresh shows a retry message" | fake throws, assert message | the model — method |
| "the total is bold and orange" | you'd have to look at it | the view |
| "tapping refresh calls the API once" | fake counts calls | model + fake |
| "HTTP 401 means re-authenticate" | fake transport returns 401 | the 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.
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)A teammate argues: "Injecting protocols everywhere is enterprise ceremony — URLSession.shared works fine." Which is the strongest counter?
Extract the logic from the view
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.
Inject the failure
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.
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.
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.