MVVM Architecture Setup for iOS App
MVVM on iOS — not one pattern, but family of implementations. MVVM with Combine, MVVM with async/await and @Observable, MVVM with RxSwift — technically different approaches with one name. Correct setup depends on target iOS version and team preferences.
MVVM with Combine (iOS 13+)
Classic implementation with ObservableObject and @Published:
final class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var error: AppError?
private let userRepository: UserRepository
private var cancellables = Set<AnyCancellable>()
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func loadProfile() {
isLoading = true
userRepository.fetchCurrentUser()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.error = error
}
},
receiveValue: { [weak self] user in
self?.user = user
}
)
.store(in: &cancellables)
}
}
Weak points: cancellables must be explicitly stored (otherwise subscription immediately cancels), memory leaks via [weak self] in closures — typical crash cause on back navigation. Configure deinit with logging to verify lifecycle in debug.
MVVM with @Observable (iOS 17+)
@Observable macro from Observation framework removes boilerplate:
@Observable
final class ProfileViewModel {
var user: User?
var isLoading = false
var error: AppError?
private let userRepository: UserRepository
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func loadProfile() async {
isLoading = true
defer { isLoading = false }
do {
user = try await userRepository.fetchCurrentUser()
} catch {
self.error = error as? AppError
}
}
}
SwiftUI automatically tracks dependencies — re-render only when used properties change. No @Published, no cancellables. Downside: iOS 17+ only, limiting adoption until end of 2025 for most projects with broad audience.
Coordinator Pattern + MVVM
Clean MVVM doesn't solve navigation. ViewModel shouldn't know about screens. Coordinator encapsulates navigation logic:
protocol ProfileCoordinator: AnyObject {
func showEditProfile(user: User)
func showSettings()
}
final class ProfileViewModel {
weak var coordinator: ProfileCoordinator?
// ...
func editProfileTapped() {
guard let user else { return }
coordinator?.showEditProfile(user: user)
}
}
Coordinator creates ViewModel and injects dependencies. ViewModel doesn't import UIKit — tested in isolation without running simulator.
Dependency Injection
Without DI MVVM becomes ProfileViewModel() with UserRepository() directly inside — impossible to mock in tests. Set up DI container:
- Resolver (Swinject-fork) — popular, lightweight
-
Swift Dependency from PointFree — strict, with dependency control support in tests via
withDependencies - Manual DI via
Environment— acceptable for small projects
Testability
This main MVVM advantage when properly set up:
func testLoadProfile_success() async {
let mockRepository = MockUserRepository(result: .success(User.fixture))
let sut = ProfileViewModel(userRepository: mockRepository)
await sut.loadProfile()
XCTAssertEqual(sut.user?.id, User.fixture.id)
XCTAssertFalse(sut.isLoading)
XCTAssertNil(sut.error)
}
No XCTestExpectation for async — with async/await ViewModel tests write linearly.
What we set up
Analyze current project structure → choose Combine or @Observable based on min deployment target → create base ViewModel protocols → configure Coordinator for navigation → set up DI → create examples for team with unit tests. If needed — refactor existing MVC ViewController to MVVM.
Work takes 2–4 days for new project. Legacy MVC → MVVM migration depends on existing code volume.







