What does MVVM look like in a real SwiftUI app?
MVVM in SwiftUI isn't a ceremony of interfaces — it's a thin, testable ViewModel holding @Published state, a View that only renders it, and a Model that knows nothing about either.
LEARN BY DOINGPut your intuition to work3 steps
What does MVVM look like in a real SwiftUI app?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.SwiftUI arguably nudges a codebase toward MVVM for free, so the question interviewers are really asking is whether you can describe the boundaries precisely rather than just naming the three letters. The View is a SwiftUI struct: declarative, disposable, re-created constantly, and carrying no business logic of its own. The ViewModel is an ObservableObject (or an @Observable class in newer code) that holds presentation state and orchestrates use cases. The Model layer is plain data types plus services — networking, persistence — with zero awareness that a UI even exists.
The boundaries matter more than the names, and each layer has a specific thing it must not do. A View must never call a network or persistence API directly, and must never own a business rule — "is this email valid" belongs in the ViewModel, not scattered across a handful of .onChange handlers in the view. A Model must stay a plain, easily testable value type with no import of SwiftUI or Combine's UI-facing pieces. And a ViewModel must not import SwiftUI's rendering vocabulary at all — no Color, no Font, no Image. If a ViewModel needs to express "this field should look like an error," it exposes a semantic value (an enum case, a Bool) and lets the View decide what Color that maps to, which is exactly what keeps the ViewModel testable without booting a simulator.
Concretely, a ViewModel's job is to expose @Published state the View binds to, expose intents as plain functions the View calls from button actions (func submit(), func retry(), never a body of logic embedded in a SwiftUI action closure), own the dependencies it needs — a repository or service, injected through its initializer rather than reached for as a global — and translate raw model or network errors into view-presentable state, typically an enum like .idle / .loading / .loaded([Item]) / .failed(String) rather than leaking a raw Error into the View.
That last point about dependency injection is also the entire payoff of doing this correctly: because the ViewModel does not import SwiftUI's rendering types, it is a plain Swift object you can unit test by injecting a fake service and asserting on @Published state transitions, with no simulator and no host app required. This is usually the concrete reason an interviewer wants MVVM mentioned at all — not "it's what the tutorials do," but "business logic is separated from render code and is provably testable."
enum LoginState: Equatable {case idlecase loadingcase successcase failed(String)}protocol AuthService {func login(email: String, password: String) async throws -> Bool}@MainActorfinal class LoginViewModel: ObservableObject {@Published var email: String = ""@Published var password: String = ""@Published private(set) var state: LoginState = .idleprivate let authService: AuthServiceinit(authService: AuthService) {self.authService = authService}var canSubmit: Bool {email.contains("@") && password.count >= 8}func submit() {guard canSubmit else {state = .failed("Enter a valid email and an 8+ character password.")return}state = .loadingTask {do {let ok = try await authService.login(email: email, password: password)state = ok ? .success : .failed("Invalid credentials.")} catch {state = .failed(error.localizedDescription)}}}}struct LoginView: View {@StateObject private var viewModel: LoginViewModelinit(authService: AuthService) {_viewModel = StateObject(wrappedValue: LoginViewModel(authService: authService))}var body: some View {VStack(spacing: 16) {TextField("Email", text: $viewModel.email).textInputAutocapitalization(.never)SecureField("Password", text: $viewModel.password)switch viewModel.state {case .loading:ProgressView()case .failed(let message):Text(message).foregroundStyle(.red)default:EmptyView()}Button("Log in", action: viewModel.submit).disabled(!viewModel.canSubmit)}.padding()}}
Two deviations are worth naming unprompted, because pointing them out is what separates "I followed a tutorial" from "I've maintained this in a real codebase." The first is the "Massive ViewModel" anti-pattern: MVVM does not automatically prevent bloat, and a ViewModel that absorbs every concern of a screen — networking, formatting, navigation, validation — has just relocated Massive View Controller one layer over, under a different name. The fix is the same discipline that fixed the original problem: extract collaborators (a formatter, a validator, a use case object) instead of letting one object grow without bound. The second is knowing when not to reach for a ViewModel at all — a stateless row that only renders data already computed upstream does not need one, and over-applying MVVM to every small subview adds ceremony with no testability payoff. Some teams now deliberately use MV (a View observing an @Observable Model directly, no formal ViewModel) for simple screens, reserving explicit ViewModels for screens with real orchestration — worth mentioning as the live debate it currently is in the SwiftUI community, not settled doctrine.
🧠 Check your understanding
0/3 · 0/3 answered1. In a strict MVVM split, which layer is allowed to import SwiftUI types like Color or Image?
2. Why is a ViewModel that never imports SwiftUI easier to unit test?
3. What is the 'Massive ViewModel' anti-pattern?