Answer
The core idea
MVVM is still a strong default for SwiftUI apps. The View stays a thin renderer, the ViewModel owns presentation state and user events, and a Provider (or repository) owns side effects. On iOS 17+, @Observable does most of the reactive plumbing that used to require Combine; on older targets the same boundaries work with ObservableObject and @Published.
The short interview answer is: MVVM remains a good choice because testable ViewModels, injected Providers, and thin Views keep the app cheap to change — Observation changed the wiring, not the reason to use the pattern.
1. The boundaries that stay the same
Three production rules survive both eras:
- Providers own side effects — networking, disk, keychain, analytics adapters.
- ViewModels orchestrate — they map Provider state into view input, handle events, and stay free of UIKit / SwiftUI view types so they stay unit-testable.
- Views never talk to Providers — a Coordinator, factory, or composition root wires the graph.
When those rules hold, swapping Combine observation for @Observable is mostly mechanical. When they do not, both eras produce the same failure modes: fat ViewModels, untestable screens, and navigation logic trapped inside views.
A useful mental model:
| Layer | Owns | Does not own |
|---|---|---|
| Provider | Side effects, domain state | UI layout, navigation |
| ViewModel | Presentation state, events | Networking, UIKit imports |
| View | Rendering, local ephemeral UI | Business rules, persistence |
| Coordinator / factory | Wiring and navigation | Screen rendering |
2. Before iOS 17: ObservableObject + Combine
Pre-iOS 17 SwiftUI MVVM usually looks like this:
- Provider exposes
@Publishedstate or anAnyPublisher - ViewModel conforms to
ObservableObject, subscribes to the Provider, and publishesviewInputData - View owns the ViewModel with
@StateObject(or receives it with@ObservedObject)
import Combine
struct NotesListState {
var notes: [Note] = []
}
protocol NotesListProviding {
var state: AnyPublisher<NotesListState, Never> { get }
func reload() async
}
final class NotesListProvider: NotesListProviding {
@Published private(set) var currentState = NotesListState()
var state: AnyPublisher<NotesListState, Never> {
$currentState.eraseToAnyPublisher()
}
private let store: NoteStoring
init(store: NoteStoring) { self.store = store }
func reload() async {
let notes = (try? await store.all()) ?? []
await MainActor.run { currentState.notes = notes }
}
}
struct NotesListViewInputData {
let rows: [NoteRowModel]
}
enum NotesListEvent {
case viewDidAppear
case select(Note.ID)
case createTapped
}
@MainActor
final class NotesListViewModel: ObservableObject {
@Published private(set) var input = NotesListViewInputData(rows: [])
var onNoteSelected: ((Note) -> Void)?
var onCreateNote: (() -> Void)?
private let provider: NotesListProviding
private var cancellables = Set<AnyCancellable>()
init(provider: NotesListProviding) {
self.provider = provider
provider.state
.map { state in
NotesListViewInputData(
rows: state.notes.map(NoteRowModel.init)
)
}
.receive(on: RunLoop.main)
.assign(to: &$input)
}
func send(_ event: NotesListEvent) {
switch event {
case .viewDidAppear:
Task { await provider.reload() }
case .select(let id):
// Resolve note, then hand navigation to the coordinator.
break
case .createTapped:
onCreateNote?()
}
}
}
struct NotesListView: View {
@StateObject private var viewModel: NotesListViewModel
var body: some View {
List(viewModel.input.rows) { row in
Button(row.title) {
viewModel.send(.select(row.id))
}
}
.onAppear { viewModel.send(.viewDidAppear) }
}
}Interview-relevant costs of this era:
- every reactive field needs
@Published(easy to forget) - change notifications are usually object-broad, which can over-invalidate large screens
- Combine subscription lifetime (
cancellables) becomes part of every ViewModel
Reach for this shape when the deployment target is still below iOS 17, or when the codebase already has Combine Providers shared with UIKit.
3. After iOS 17: @Observable + structured concurrency
On iOS 17+, the same boundaries map onto Observation and async/await more cleanly:
- Provider can stay a protocol with
asyncmethods (and become anactorwhen it has shared mutable state) - ViewModel is
@MainActorand@Observable - View owns it with
@State, uses@Bindablewhen child bindings are needed, and loads with.task
import Observation
protocol NotesProviding {
func fetchAll() async throws -> [Note]
}
@MainActor
@Observable
final class NotesListViewModel {
private(set) var rows: [NoteRowModel] = []
private(set) var errorMessage: String?
var onNoteSelected: ((Note) -> Void)?
var onCreateNote: (() -> Void)?
private let provider: NotesProviding
init(provider: NotesProviding) {
self.provider = provider
}
func load() async {
do {
let notes = try await provider.fetchAll()
rows = notes.map(NoteRowModel.init)
} catch is CancellationError {
// Expected when the view disappears and `.task` cancels.
} catch {
errorMessage = "Could not load notes."
}
}
func select(_ id: Note.ID) {
// Resolve note, then call onNoteSelected for navigation.
}
}
struct NotesListView: View {
@State private var viewModel: NotesListViewModel
var body: some View {
List(viewModel.rows) { row in
Button(row.title) {
viewModel.select(row.id)
}
}
.task {
await viewModel.load() // ✅ Cancellation is tied to view lifetime
}
}
}Compared with the Combine version:
- no
@Publishedchecklist - no
AnyCancellablebag for the happy path - SwiftUI tracks which properties
bodyactually read, so unrelated mutations invalidate fewer subtrees (see SE-0395: Observability and ObservableObject vs @Observable) .taskmakes screen-scoped cancellation first-class
Ownership still matters. @Observable changes how invalidation works; it does not remove the need for a stable owner. Prefer @State for a ViewModel the screen creates, and pass it down as a plain dependency to children.
4. Wiring, navigation, and DI — unchanged levers
MVVM becomes cheap to change when three supporting pieces are present.
Coordinators (or an equivalent wiring object)
Views should emit intent (createTapped, select(id)), not push destinations. A Coordinator / router decides whether that means a NavigationStack path update, a sheet, or a deep-link destination. That matters as soon as one screen is reused from search, share, and push flows.
Reach for Coordinators when there are multiple feature flows, deep links, or reused entry points. Skip them for a short linear wizard.
Dependency injection
Inject Providers into ViewModels through initializers (or a DI container). That is what makes unit tests swap a mock Provider in one line instead of spinning up networking. Common SwiftUI-friendly options include Factory; UIKit-heavy apps often use DITranquillity or Swinject. For a one-screen utility, plain initializer injection is enough.
Keep ViewModels thin
If a ViewModel grows past a few hundred lines, it has usually absorbed Provider work. Push pure domain logic into use cases / Providers and leave the ViewModel as an orchestrator.
5. Why it matters
Interviewers are usually checking whether the candidate can ship architecture that stays testable after the Observation migration, not whether they memorized property wrappers.
- ViewModels stay unit-testable with mocked Providers in both eras
- Screens redraw less on iOS 17+ when Observation tracks real property dependencies
.taskcancellation reduces orphaned network work when users leave mid-fetch- Coordinated navigation keeps deep links and reused screens from coupling View files together
- The same Provider contracts can serve UIKit today and SwiftUI tomorrow during a gradual migration
Interview angle
Walk the answer in this order: MVVM is Provider / ViewModel / View boundaries first -> before iOS 17 those boundaries use ObservableObject, @Published, and @StateObject / @ObservedObject -> after iOS 17 the same boundaries use @Observable, @State / @Bindable, and .task -> Coordinators and DI stay the leverage points for navigation and tests -> call out fat ViewModels and Views talking to Providers as the failure modes that Observation will not fix. Mention SE-0395 if asked why @Observable invalidates more precisely.