MVVM Architecture Setup for iOS: Combine vs @Observable
We often encounter projects built on MVC where the ViewController swells to thousands of lines: network requests, data processing, UI updates—all in one file. Such architecture complicates testing and maintenance. Switching to MVVM solves these problems: the ViewModel takes over business logic, and the View remains "dumb." However, setting up MVVM on iOS is not a universal template: the choice between Combine, @Observable, and RxSwift affects performance and compatibility. Our experience shows that a properly selected implementation reduces the time to implement new functionality by 40% and cuts regression bugs by a factor of 3 due to logic isolation. We guarantee a stable architecture that complies with the App Store Review Guidelines.
Which MVVM implementation to choose for your project?
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 the subscription is immediately cancelled), memory leaks via [weak self] in closures—a typical crash cause when navigating back. We configure deinit with logging for lifecycle verification in debug.
MVVM with @Observable (iOS 17+)
The @Observable macro from the 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-renders only when used properties change. No @Published, no cancellables. Downside: iOS 17+ only, which limits use for projects with a wide audience.
Comparison of Combine and @Observable
| Criteria | Combine (iOS 13+) | @Observable (iOS 17+) |
|---|---|---|
| Minimum iOS version | 13.0 | 17.0 |
| Syntax | @Published, ObservableObject |
@Observable macro |
| Memory management | Explicit cancellables |
Automatic |
| Testing | Using Testing or Combine |
async/await directly |
| Performance | Manual optimization | Automatic re-render |
Why is Dependency Injection important?
Without DI, a ViewModel creates dependencies inside itself—testing becomes impossible. Using a DI container allows swapping real services with mocks in a couple of lines of code. For example, in tests we pass a MockUserRepository, which returns prepared data instead of hitting the network. This cuts test execution time from 10 seconds to 0.1 seconds per test.
Here is a comparison of popular DI frameworks:
| Framework | Swift | Minimum iOS | Popularity |
|---|---|---|---|
| Swinject | 5.7+ | 9.0 | High |
| Resolver | 5.0+ | 9.0 | Medium |
| SwiftDependencies | 5.9+ | 13.0 | Growing |
The choice depends on language version and testing requirements. We recommend SwiftDependencies for new projects on iOS 17+ due to strong typing and built-in test support.
How to implement MVVM painlessly: step-by-step guide?
- Analyze the current project structure and identify modules.
- Choose the appropriate implementation: Combine for iOS 13+, @Observable for iOS 17+, RxSwift if legacy code exists.
- Create base protocols for
ViewModelandCoordinator. - Set up a DI container (Swinject, Resolver, or SwiftDependencies).
- Refactor one screen from MVC to MVVM to demonstrate to the team.
- Write unit tests for the new
ViewModelusing mock repositories. - Document the architecture and conduct code review.
This process takes 2 to 5 days for a new project. Migration from legacy MVC to MVVM is evaluated individually—contact us for a free assessment.
How to test ViewModel with async/await?
Thanks to async/await, tests become linear. Example:
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 need for XCTestExpectation with async—using async/await, ViewModel tests are written linearly and read like regular code.
Coordinator pattern + MVVM
Pure MVVM does not address navigation. The ViewModel should not know about screens. A Coordinator encapsulates navigation logic:
Example Coordinator for Profile
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) } } The Coordinator creates the ViewModel and injects dependencies. The ViewModel does not import UIKit—it can be tested in isolation without launching the simulator.
What's included in MVVM setup
We provide a full package: analysis of the current project structure, selection of the appropriate implementation (Combine or @Observable), creation of base ViewModel protocols, Coordinator setup for navigation, DI container configuration, and sample unit tests for the team. If needed, refactoring existing MVC ViewControllers to MVVM. The result is a documented architecture reproducible on other projects. Our engineers, with over 10 years of experience and 40+ successfully delivered projects, ensure adherence to best practices and code style. Get a consultation on choosing the right MVVM implementation for your project—free of charge.
Timelines
Architecture setup takes 2 to 5 days for a new project. Migration from legacy MVC to MVVM is evaluated individually—timelines depend on code volume and ViewController complexity. Contact us for a project assessment—free and takes no more than an hour.







