mvvm-architecture
MVVM Architecture — Expert Decisions
Expert decision frameworks for MVVM choices in iOS/tvOS. Claude knows MVVM basics — this skill provides judgment calls for non-obvious decisions.
Decision Trees
ViewModel Pattern Selection
Does the screen have distinct, mutually exclusive states?
├─ YES (loading → loaded → error)
│ └─ State Enum Pattern
│ @Published var state: State = .idle
│ enum State { case idle, loading, loaded(Data), error(String) }
│
└─ NO (multiple independent properties)
└─ Does the screen need form validation?
├─ YES → Combine Pattern (publishers for validation chains)
└─ NO → Published Properties Pattern (simplest)
When State Enum wins: Product detail (loading → product → error), authentication flows, wizard steps. Forces exhaustive handling.
When Published Properties win: Dashboard with multiple independent sections that load/fail independently. State enum becomes unwieldy with 2^n combinations.
Where Does Logic Belong?
Is it data transformation for display?
├─ YES → ViewModel (formatting, filtering visible data)
│
└─ NO → Is it reusable business logic?
├─ YES → Service Layer (API calls, validation rules, caching)
│
└─ NO → Is it pure domain logic?
├─ YES → Model (computed properties, domain rules)
└─ NO → Reconsider if it's needed
The trap: Putting API calls directly in ViewModel. Makes testing require network mocking instead of simple service mocking.
@StateObject Injection
Does ViewModel need dependencies from parent?
├─ NO → Direct initialization
│ @StateObject private var viewModel = UserViewModel()
│
└─ YES → How many dependencies?
├─ 1-2 → Init parameter
│ init(userId: String) {
│ _viewModel = StateObject(wrappedValue: UserViewModel(userId: userId))
│ }
│
└─ Many → Factory/Container
@StateObject private var viewModel: UserViewModel
init() {
_viewModel = StateObject(wrappedValue: Container.shared.makeUserViewModel())
}
NEVER Do
ViewModel Anti-Patterns
NEVER load data in ViewModel init:
// ❌ Starts loading before view appears, can't cancel, can't retry
class BadViewModel: ObservableObject {
init() {
Task { await loadData() } // Fire-and-forget in init
}
}
// ✅ Load via .task modifier — automatic cancellation on disappear
struct GoodView: View {
@StateObject var viewModel = GoodViewModel()
var body: some View {
content.task { await viewModel.loadData() }
}
}
NEVER expose mutable state directly:
// ❌ Anyone can mutate — no control over state transitions
class BadViewModel: ObservableObject {
@Published var users: [User] = [] // Public setter
}
// ✅ private(set) — only ViewModel controls mutations
class GoodViewModel: ObservableObject {
@Published private(set) var users: [User] = []
func addUser(_ user: User) {
// Validation, analytics, etc.
users.append(user)
}
}
NEVER put UI-specific code in ViewModel:
// ❌ ViewModel knows about colors, fonts, formatters
class BadViewModel: ObservableObject {
@Published var priceColor: Color = .green
@Published var formattedDate: String = "" // Pre-formatted for display
}
// ✅ Return data, let View handle presentation
class GoodViewModel: ObservableObject {
@Published private(set) var price: Decimal = 0
@Published private(set) var date: Date = .now
}
// View: Text(viewModel.price, format: .currency(code: "USD"))
NEVER create god ViewModels:
// ❌ One ViewModel for entire feature area
class UserViewModel: ObservableObject {
// Profile, settings, posts, friends, notifications, activity...
// 50+ @Published properties, 30+ methods
}
// ✅ One ViewModel per screen/concern
class UserProfileViewModel: ObservableObject { }
class UserSettingsViewModel: ObservableObject { }
class UserPostsViewModel: ObservableObject { }
Service Layer Anti-Patterns
NEVER use concrete dependencies:
// ❌ Hard to test — must mock URLSession
class BadViewModel: ObservableObject {
func loadUsers() async {
let url = URL(string: "https://api.example.com/users")!
let (data, _) = try await URLSession.shared.data(from: url) // Concrete
}
}
// ✅ Protocol dependency — inject mock for testing
protocol UserServiceProtocol {
func fetchUsers() async throws -> [User]
}
class GoodViewModel: ObservableObject {
private let userService: UserServiceProtocol
init(userService: UserServiceProtocol = UserService()) {
self.userService = userService
}
}
NEVER ignore task cancellation:
// ❌ Shows error for cancelled task (user navigated away)
func loadData() async {
do {
users = try await service.fetchUsers()
} catch {
errorMessage = error.localizedDescription // CancellationError shows error!
}
}
// ✅ Handle cancellation separately
func loadData() async {
do {
users = try await service.fetchUsers()
} catch is CancellationError {
return // User navigated away — don't show error
} catch {
errorMessage = error.localizedDescription
}
}
Core Patterns
Minimal ViewModel Template
@MainActor
final class FeatureViewModel: ObservableObject {
// MARK: - State
@Published private(set) var items: [Item] = []
@Published private(set) var isLoading = false
@Published var error: Error?
// MARK: - Dependencies
private let service: ServiceProtocol
init(service: ServiceProtocol = Service()) {
self.service = service
}
// MARK: - Actions
func loadItems() async {
isLoading = true
defer { isLoading = false }
do {
items = try await service.fetchItems()
} catch is CancellationError {
return
} catch {
self.error = error
}
}
}
State Enum Pattern
@MainActor
final class DetailViewModel: ObservableObject {
enum State: Equatable {
case idle
case loading
case loaded(Item)
case error(String)
var item: Item? {
guard case .loaded(let item) = self else { return nil }
return item
}
}
@Published private(set) var state: State = .idle
func load(id: String) async {
state = .loading
do {
let item = try await service.fetch(id: id)
state = .loaded(item)
} catch {
state = .error(error.localizedDescription)
}
}
}
// View exhaustive handling
switch viewModel.state {
case .idle, .loading: ProgressView()
case .loaded(let item): ItemView(item: item)
case .error(let message): ErrorView(message: message)
}
Service Protocol Pattern
// Protocol — the contract
protocol UserServiceProtocol {
func fetchUser(id: String) async throws -> User
func updateUser(_ user: User) async throws -> User
}
// Real implementation
final class UserService: UserServiceProtocol {
private let client: NetworkClient
func fetchUser(id: String) async throws -> User {
try await client.request(.user(id: id))
}
}
// Mock for testing
final class MockUserService: UserServiceProtocol {
var stubbedUser: User?
var fetchError: Error?
var fetchCallCount = 0
func fetchUser(id: String) async throws -> User {
fetchCallCount += 1
if let error = fetchError { throw error }
return stubbedUser ?? User.mock()
}
}
Testing Strategy
ViewModel Test Structure
@MainActor
final class UserViewModelTests: XCTestCase {
var sut: UserViewModel!
var mockService: MockUserService!
override func setUp() {
mockService = MockUserService()
sut = UserViewModel(service: mockService)
}
func test_loadUser_success_updatesState() async {
// Given
mockService.stubbedUser = User.mock(name: "John")
// When
await sut.loadUser(id: "123")
// Then
XCTAssertEqual(sut.user?.name, "John")
XCTAssertFalse(sut.isLoading)
XCTAssertNil(sut.error)
}
func test_loadUser_failure_setsError() async {
// Given
mockService.fetchError = NetworkError.noConnection
// When
await sut.loadUser(id: "123")
// Then
XCTAssertNil(sut.user)
XCTAssertNotNil(sut.error)
}
}
Test what matters:
- State changes on success/failure
- Service method called with correct parameters
- Loading states transition correctly
- Error handling doesn't crash
Don't test:
- SwiftUI bindings (Apple's responsibility)
- Service implementation (separate test file)
Dependency Injection
Simple: Default Parameters
// Most apps need nothing more complex
class UserViewModel: ObservableObject {
init(service: UserServiceProtocol = UserService()) {
self.service = service
}
}
// Test: UserViewModel(service: MockUserService())
// Production: UserViewModel() — uses default
Complex: Factory Container
// Only when you have many cross-cutting dependencies
@MainActor
final class Container {
static let shared = Container()
lazy var networkClient = NetworkClient()
lazy var authService = AuthService(client: networkClient)
lazy var userService = UserService(client: networkClient, auth: authService)
func makeUserViewModel() -> UserViewModel {
UserViewModel(service: userService)
}
}
Quick Reference
Layer Responsibilities
| Layer | Contains | Examples |
|---|---|---|
| Model | Domain data + pure logic | User, Order, validation rules |
| ViewModel | Screen state + UI logic | Loading/error states, list filtering |
| Service | Business operations | API calls, caching, persistence |
| View | Presentation | Layout, styling, animations |
ViewModel Checklist
-
@MainActoron class -
private(set)on @Published properties - Protocol-based dependencies with defaults
- CancellationError handled separately
- No UI types (Color, Font, etc.)
- No direct network/database calls
- Testable without UI framework