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() }
    }
}
  • @Observable replaces ObservableObject and @Published: SwiftUI reads the properties a view touches and only re-renders that view.
  • Create the model with @State in the view that owns it — @State keeps 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

LayerOwnsMust not
ViewLayout and user intentParse JSON or hold business rules
ViewModelScreen state and use casesImport SwiftUI beyond Observation
ServiceNetwork, storage, platform APIsKnow which screen called it
ModelPlain data typesPerform 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.

Networking with URLSession and async/await Testing, debugging and Instruments

Last refreshed 2026-09-18.