Question 1
MVVM: where logic lives in UIKit vs SwiftUI
Your team is standardizing on MVVM. How do you decide what belongs in the view controller or view vs the view model? What mistakes create 'fat' view models or leaked UIKit in the domain layer?
Follow-ups
- How do you handle navigation from the view model without tight coupling?
Answer outline
The view model owns presentation state and user intent. That covers what to display, whether a button is enabled, which message a domain error maps to, and the async commands the view triggers. It shouldn't know about UILabel, layout constraints, or how a UIImage gets built.
The view or view controller owns lifecycle, animations, and wiring. It forwards user events to the view model and renders whatever the view model exposes, whether that's @Published properties, @Observable change tracking, or bindings.
Fat view models come from the opposite leak. Domain rules and networking that belong behind a service or repository get written straight into the view model. A massive view controller is the same mistake one layer up. Move that logic behind protocols the view model calls, and keep domain models free of UIKit types.
Principles
- The view model holds presentation state and intent, never
UILabel, constraints, orUIImageconstruction. - Domain models stay framework-agnostic, and mapping to display types happens in the view model or a mapper.
- In SwiftUI, mark view models
@MainActorso state changes always land on the main thread. ObservableObjectwith@Publisheddrives SwiftUI updates through Combine'sobjectWillChangepublisher.@Observableis a macro that synthesizes change tracking for stored properties, so views track only what they read.
The controller depends on a protocol, so a test can hand it a fake view model:
protocol ProfileViewModeling: AnyObject {
var displayName: String { get }
func load()
func saveTapped()
}
final class ProfileViewController: UIViewController {
private let viewModel: ProfileViewModeling
init(viewModel: ProfileViewModeling) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
// Bind labels in viewDidLoad and forward actions only
}
The model owns the count, and the view only reads it and calls increment:
@Observable
final class CounterModel {
private(set) var count = 0
func increment() { count += 1 }
}
struct CounterView: View {
let model: CounterModel
var body: some View {
Text("\(model.count)")
Button(" + ") { model.increment() }
}
}
Follow-up angles
- Keep navigation out of the view model by having it emit a route value, such as a case of a small
Routeenum. A coordinator, closure, or router protocol then turns that route into the actual push or present.



