As an app grows, views fill up with logic: validation, network calls, sorting rules. They become hard to read and impossible to test without a screen. Architecture is the set of habits that keeps each piece of code in a sensible place. Interviewers ask about it a lot, and teams use it to keep big apps maintainable. This lesson explains the pattern most SwiftUI teams use, MVVM, and then walks through the others you will hear about.
MVVM in one picture
- Model: the data and its rules. Your structs and
@Modelclasses, such asExpense. - View: what the user sees. A SwiftUI struct that shows data and sends user actions.
- ViewModel: a class in the middle. It holds the state the view needs, and does the work: loads from the network, validates, sorts, and decides what to show.
The view *reads* the view model and *tells* it what the user did. The view model knows nothing about SwiftUI controls. That makes it easy to test, because a test can create a view model and call its methods with no screen at all.
@Observable
In iOS 17 Apple added the @Observable macro. Put it on a class, and SwiftUI watches the properties that a view actually reads. When one of them changes, only the views that used it are redrawn. Before this, you used ObservableObject with @Published properties and @StateObject. You will see that style in older tutorials and code bases, and it relates to the Combine framework, Apple's older tool for streams of values over time. New code on iOS 17 and up does not need either of them for this job.
import SwiftUI
import Observation
@MainActor
@Observable
final class WeatherViewModel {
enum State {
case idle
case loading
case loaded(Weather)
case failed(String)
}
private(set) var state: State = .idle
private let service: any WeatherServing
init(service: any WeatherServing) {
self.service = service
}
func load(latitude: Double, longitude: Double) async {
state = .loading
do {
let weather = try await service.fetch(latitude: latitude, longitude: longitude)
state = .loaded(weather)
} catch {
state = .failed("Could not load the weather. Check your connection.")
}
}
}
@MainActor from lesson 10 makes sure this class changes the screen's data on the main thread. private(set) means the view can read state but only the view model can change it. And the service arrives through the init, which brings us to the second big idea.
Dependency injection
Dependency injection sounds grand and means something simple: *a type receives the things it needs from outside, and does not create them itself.* The view model above does not write WeatherService() inside. It asks for something that can fetch weather. We describe that ability with a protocol, from lesson 4.
protocol WeatherServing {
func fetch(latitude: Double, longitude: Double) async throws -> Weather
}
// the real one from lesson 11
extension WeatherService: WeatherServing {}
// a fake one for previews and tests
struct FakeWeatherService: WeatherServing {
var result: Result<Weather, Error>
func fetch(latitude: Double, longitude: Double) async throws -> Weather {
try result.get()
}
}
Now the view model works with any type that meets the protocol. In the real app you give it the real service. In a preview you give it a fake one that answers at once with sample data, with no internet. In a test you give it a fake that fails on purpose, to check your error handling. This is the main practical reason for the whole idea.
Using the view model in a view
struct WeatherScreen: View {
@State private var viewModel: WeatherViewModel
init(service: any WeatherServing = WeatherService()) {
_viewModel = State(initialValue: WeatherViewModel(service: service))
}
var body: some View {
VStack(spacing: 16) {
switch viewModel.state {
case .idle, .loading:
ProgressView()
case .loaded(let weather):
Text("\(weather.current.temperature, specifier: "%.1f")")
.font(.largeTitle)
case .failed(let message):
Text(message)
Button("Try again") {
Task { await viewModel.load(latitude: 52.52, longitude: 13.41) }
}
}
}
.task { await viewModel.load(latitude: 52.52, longitude: 13.41) }
}
}
With an @Observable class, the view keeps it in @State, so that SwiftUI creates it once and keeps it alive. For something shared by many screens, such as the logged-in user or app settings, you can put the object in the environment with .environment(settings) at the top, and read it in any view with @Environment(Settings.self).
The other names you will hear
- MVC (Model View Controller): Apple's original pattern for UIKit. The view controller often grew huge, which gave rise to the joke name *Massive View Controller*. MVVM was partly a reaction to it.
- VIPER (View, Interactor, Presenter, Entity, Router): splits an app into many small pieces with strict roles. It brings a lot of files, so you see it in some large UIKit teams.
- Clean Architecture: arranges code in layers, so that business rules do not depend on screens, databases or the network.
- Coordinator pattern: one object decides which screen comes next, so that screens do not know about each other. Popular in UIKit. SwiftUI's
NavigationStackcovers much of this need. - Repository pattern: one type is the single place to get and save a kind of data. The view model asks the repository, and does not care whether the data comes from the network, a file or the database.
- SOLID: five design habits, such as *one reason to change per type* and *depend on protocols, not on concrete types*. What you did with
WeatherServingis one of them.
Common mistakes
- Creating the service inside the view model, which makes tests need the real network.
- Passing the whole view model to every small child view. Pass just the values and closures the child needs.
- Making a view model that imports SwiftUI types, such as
Color, which ties logic to the screen.
Your task
- Move the networking logic of
WeatherViewintoWeatherViewModel. - Add the
WeatherServingprotocol and aFakeWeatherService. - Use the fake in a
#Previewso that it shows a temperature with no internet. - Write down, in your own words, three things MVVM gives you and one case where it would be too much.
How to check it worked
- The app works as before, and the preview works without a network.
- The view does not contain any call to
URLSession. - You can explain dependency injection in one sentence, with the weather example.