Architecture: MVVM, observation and dependency injection
Separate view from logic with view models, use the Observation framework instead of ObservableObject, inject dependencies through the environment, and design for tests.
A view model with @Observable
protocol CounterStoring {
func load() -> Int
func save(_ value: Int)
}
@Observable
final class CounterViewModel {
var count = 0
var history: [Int] = []
var lastError: String?
private let store: CounterStoring
init(store: CounterStoring) { self.store = store }
func increment() {
count += 1
history.append(count)
store.save(count)
}
func restore() {
count = store.load()
}
}
struct CounterView: View {
@State private var model: CounterViewModel
init(store: CounterStoring) {
_model = State(initialValue: CounterViewModel(store: store))
}
var body: some View {
VStack(spacing: 16) {
Text("Count: \(model.count)").font(.title)
Button("Increment") { model.increment() }
}
.task { model.restore() }
}
}@ObservablereplacesObservableObjectand@Published: SwiftUI reads the properties a view touches and only re-renders that view.- Create the model with
@Statein the view that owns it —@Statekeeps the instance alive across re-renders. - Inject the model into children with
.environment(model)and read it with@Environment(CounterViewModel.self). - Keep view models free of UIKit types so they can run in a unit test with no simulator.
Where the layers stop
| Layer | Owns | Must not |
|---|---|---|
| View | Layout and user intent | Parse JSON or hold business rules |
| ViewModel | Screen state and use cases | Import SwiftUI beyond Observation |
| Service | Network, storage, platform APIs | Know which screen called it |
| Model | Plain data types | Perform I/O |
The rule is not "always three layers". It is that a type should have one reason to change. A settings screen that only writes a boolean needs no view model at all.
Injecting for tests
final class InMemoryStore: CounterStoring {
private var value = 0
func load() -> Int { value }
func save(_ newValue: Int) { value = newValue }
}
@Test
func incrementStoresTheNewValue() {
let store = InMemoryStore()
let model = CounterViewModel(store: store)
model.increment()
model.increment()
#expect(model.count == 2)
#expect(store.load() == 2)
}⚠️
Injecting a concrete singleton anywhere a view model is created makes the model untestable and hides the dependency. Accept a protocol in the initialiser with a production default:
init(store: CounterStoring = KeychainStore()).FAQ
Does MVVM always mean one view model per screen?
No. Split by feature or by piece of state that changes together. A giant view model with fifty properties is harder to reason about than three focused ones composed in the view.
Where should navigation live?
Keep a small routing object or a path array that the view observes. Views trigger navigation by intent (
model.openDetail(id)) rather than building destinations inline, which keeps flows testable.Related
Networking with URLSession and async/await Testing, debugging and Instruments
Last refreshed 2026-09-18.