How do you implement MVVM in SwiftUI?

Contrast Combine-era ObservableObject MVVM with iOS 17+ @Observable MVVM, keeping the same Provider → ViewModel → View boundaries, DI, and testability rules

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:

  1. Providers own side effects — networking, disk, keychain, analytics adapters.
  2. ViewModels orchestrate — they map Provider state into view input, handle events, and stay free of UIKit / SwiftUI view types so they stay unit-testable.
  3. 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:

LayerOwnsDoes not own
ProviderSide effects, domain stateUI layout, navigation
ViewModelPresentation state, eventsNetworking, UIKit imports
ViewRendering, local ephemeral UIBusiness rules, persistence
Coordinator / factoryWiring and navigationScreen rendering

2. Before iOS 17: ObservableObject + Combine

Pre-iOS 17 SwiftUI MVVM usually looks like this:

  • Provider exposes @Published state or an AnyPublisher
  • ViewModel conforms to ObservableObject, subscribes to the Provider, and publishes viewInputData
  • View owns the ViewModel with @StateObject (or receives it with @ObservedObject)
swift 5.2
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 async methods (and become an actor when it has shared mutable state)
  • ViewModel is @MainActor and @Observable
  • View owns it with @State, uses @Bindable when child bindings are needed, and loads with .task
swift 5.2
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 @Published checklist
  • no AnyCancellable bag for the happy path
  • SwiftUI tracks which properties body actually read, so unrelated mutations invalidate fewer subtrees (see SE-0395: Observability and ObservableObject vs @Observable)
  • .task makes 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
  • .task cancellation 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.