Skip to content
← All guides iOS

SwiftUI Architecture: MVVM Done Right

A practical guide to swiftui mvvm architecture — how to structure ViewModels, manage state, and avoid common pitfalls in production iOS apps.

SwiftUI Architecture: MVVM Done Right

SwiftUI ships with powerful state primitives — @State, @Binding, @Observable — and it is tempting to put everything directly inside a View. That works for a counter app. It breaks down the moment you add network calls, business logic, and unit tests. The swiftui mvvm pattern fixes this cleanly, but only if you apply it with discipline. This guide shows exactly how we do it in production, drawing on lessons from shipping 12+ apps.

Why MVVM and not something else?

SwiftUI’s declarative rendering model maps naturally to MVVM:

  • Model — plain data structures and domain logic
  • ViewModel — observable state + commands; no UIKit or SwiftUI imports
  • View — renders state and forwards user intent to the ViewModel

You could use TCA, VIPER, or Clean Architecture on top of SwiftUI — and each has legitimate use cases. But for most app scopes (a product with 10–30 screens), MVVM hits the sweet spot between simplicity and testability. Heavier patterns add ceremony that slows early iteration without proportional benefit.

Rule of thumb: if your ViewModel file grows past ~200 lines, it is absorbing too many responsibilities. Split into a service layer, not into a heavier pattern.

The correct anatomy of a SwiftUI ViewModel

import Foundation
import Observation

@Observable
final class LaunchListViewModel {
    // MARK: - Published state
    var launches: [Launch] = []
    var isLoading: Bool = false
    var errorMessage: String?

    // MARK: - Dependencies (injected)
    private let repository: LaunchRepository

    init(repository: LaunchRepository = LiveLaunchRepository()) {
        self.repository = repository
    }

    // MARK: - Intent
    func loadLaunches() async {
        isLoading = true
        defer { isLoading = false }
        do {
            launches = try await repository.fetchUpcoming()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

Notice a few things that are intentional:

  • @Observable (iOS 17+) instead of ObservableObject + @Published. Apple’s new macro generates observation tracking at the property level — fewer redraws, cleaner syntax.
  • The ViewModel imports Foundation, not SwiftUI. It must remain UI-framework-agnostic.
  • The dependency (LaunchRepository) is a protocol, injected at init. This makes unit-testing trivial: swap in a mock.
  • async/await for every asynchronous intent, not callbacks or Combine chains.

Connecting it to the View

struct LaunchListView: View {
    @State private var viewModel = LaunchListViewModel()

    var body: some View {
        Group {
            if viewModel.isLoading {
                ProgressView()
            } else {
                List(viewModel.launches) { launch in
                    LaunchRow(launch: launch)
                }
            }
        }
        .task { await viewModel.loadLaunches() }
        .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
            Button("OK") { viewModel.errorMessage = nil }
        } message: {
            Text(viewModel.errorMessage ?? "")
        }
    }
}

The View owns the ViewModel as @State. This is correct in SwiftUI — the View is the lifecycle anchor. Passing a ViewModel through @Environment is appropriate for shared, screen-spanning state (e.g., auth status); per-screen ViewModels stay as @State.

Common SwiftUI MVVM mistakes — and the fixes

Mistake 1: Fat Views with inline business logic

// Wrong
Button("Book") {
    let discountedPrice = item.price * 0.9  // business logic in View
    cartItems.append(item)
    UserDefaults.standard.set(...)         // persistence in View
}

Move all logic into the ViewModel. The View sends an intent (viewModel.addToCart(item)), nothing more.

Mistake 2: ViewModel reaching into SwiftUI types

A ViewModel that imports SwiftUI to use Color, Image, or NavigationPath is a red flag. Map domain states to presentation states inside the ViewModel using plain types, then convert at the View boundary:

// In ViewModel (no SwiftUI import)
var statusLabel: String { launch.status == .go ? "GO for launch" : "On hold" }
var isStatusGreen: Bool { launch.status == .go }

// In View
Text(viewModel.statusLabel)
    .foregroundStyle(viewModel.isStatusGreen ? .green : .orange)

Mistake 3: Injecting MainActor work incorrectly

With @Observable, UI state updates must happen on the main actor. Mark the ViewModel class @MainActor to enforce this automatically:

@Observable
@MainActor
final class BookingViewModel { ... }

Do heavy async work in a detached task if needed, then publish results back through ViewModel properties — the @MainActor annotation handles the thread hop.

Mistake 4: Ignoring cancellation

Every .task modifier in SwiftUI automatically cancels its async work when the View disappears. If you spin up a Task manually inside a ViewModel, store it and cancel on deinit or on re-entry:

private var fetchTask: Task<Void, Never>?

func loadLaunches() {
    fetchTask?.cancel()
    fetchTask = Task { await performFetch() }
}

Structuring a real project

A folder structure that has worked well across our shipped apps:

Features/
  Launches/
    LaunchListView.swift
    LaunchListViewModel.swift
    LaunchDetailView.swift
    LaunchDetailViewModel.swift
Domain/
  Models/
    Launch.swift
  Repositories/
    LaunchRepository.swift       ← protocol
    LiveLaunchRepository.swift   ← network impl
    MockLaunchRepository.swift   ← test impl
Services/
  NetworkClient.swift
  PersistenceStore.swift

Keep feature Views and ViewModels co-located. Domain models and repository protocols live separately — they are shared across features and must not import anything feature-specific.

Comparison: Approaches to SwiftUI state

ApproachTestabilityBoilerplateiOS TargetBest for
@Observable MVVMHighLowiOS 17+New projects in 2025–2026
ObservableObject MVVMHighMediumiOS 14+Projects supporting older OS
TCAVery highHighAnyLarge teams, strict unidirectional flow
Plain @State / @BindingLowNoneAnySimple, leaf-level UI components
VIPER/CleanVery highVery highAnyEnterprise, large codebases

For apps targeting iOS 17+, @Observable MVVM is the clear default. If your minimum deployment target is iOS 15 or 16, ObservableObject + @Published delivers the same architectural benefits with slightly more syntax.

Testing a SwiftUI ViewModel

Because the ViewModel has no SwiftUI dependency, you test it as plain Swift:

@MainActor
final class LaunchListViewModelTests: XCTestCase {
    func testLoadPopulatesLaunches() async throws {
        let mock = MockLaunchRepository(launches: [.preview])
        let vm = LaunchListViewModel(repository: mock)
        await vm.loadLaunches()
        XCTAssertEqual(vm.launches.count, 1)
        XCTAssertFalse(vm.isLoading)
    }
}

No XCTestExpectation, no async timing hacks. Protocol-injected dependencies make this effortless — which is the single biggest reason to keep ViewModels free of concrete service implementations.

When MVVM is not enough

MVVM handles most screens well. Where it starts to strain:

  • Complex navigation flows — add a Coordinator or NavigationPath-based router alongside MVVM
  • Cross-feature shared state — elevate to an @Environment-injected domain store (e.g., AuthStore, CartStore)
  • Heavy real-time data — consider a unidirectional store for the data layer while keeping MVVM for presentation

We have shipped apps like Launchcast using exactly this tiered approach: MVVM per screen, a thin domain store for shared state, and a clean repository layer for data access.

Where to go from here

A well-structured swiftui mvvm codebase is dramatically easier to maintain, test, and hand off to new developers — and those qualities translate directly into lower ongoing engineering costs. The patterns here are the same ones we apply on every new iOS project at Fera Tech’s services.

If you are building an iOS app and want architecture done right from the start — not refactored later at a premium — get in touch with us. We will help you make the right structural decisions before a single View is written.

Building something like this?

Fera Tech ships iOS & full-stack apps end-to-end. Tell us about your project.

Start a project
Call us Open business Telegram