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 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 ofObservableObject + @Published. Apple’s new macro generates observation tracking at the property level — fewer redraws, cleaner syntax.- The ViewModel imports
Foundation, notSwiftUI. 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/awaitfor 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
| Approach | Testability | Boilerplate | iOS Target | Best for |
|---|---|---|---|---|
@Observable MVVM | High | Low | iOS 17+ | New projects in 2025–2026 |
ObservableObject MVVM | High | Medium | iOS 14+ | Projects supporting older OS |
| TCA | Very high | High | Any | Large teams, strict unidirectional flow |
Plain @State / @Binding | Low | None | Any | Simple, leaf-level UI components |
| VIPER/Clean | Very high | Very high | Any | Enterprise, 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