| name | mvvm-architecture |
| description | Expert MVVM decisions for iOS/tvOS: choosing between ViewModel patterns (state enum vs published properties vs Combine), service layer boundaries, dependency injection strategies, and testing approaches. Use when designing ViewModel architecture, debugging data flow issues, or deciding where business logic belongs. Trigger keywords: MVVM, ViewModel, ObservableObject, @StateObject, service layer, dependency injection, unit test, mock, architecture |
| version | 3.0.0 |
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:
class BadViewModel: ObservableObject {
init() {
Task { await loadData() }
}
}
struct GoodView: View {
@StateObject var viewModel = GoodViewModel()
var body: some View {
content.task { await viewModel.loadData() }
}
}
NEVER expose mutable state directly:
class BadViewModel: ObservableObject {
@Published var users: [User] = []
}
class GoodViewModel: ObservableObject {
@Published private(set) var users: [User] = []
func addUser(_ user: User) {
users.append(user)
}
}
NEVER put UI-specific code in ViewModel:
class BadViewModel: ObservableObject {
@Published var priceColor: Color = .green
@Published var formattedDate: String = ""
}
class GoodViewModel: ObservableObject {
@Published private(set) var price: Decimal = 0
@Published private(set) var date: Date = .now
}
NEVER create god ViewModels:
class UserViewModel: ObservableObject {
}
class UserProfileViewModel: ObservableObject { }
class UserSettingsViewModel: ObservableObject { }
class UserPostsViewModel: ObservableObject { }
Service Layer Anti-Patterns
NEVER use concrete dependencies:
class BadViewModel: ObservableObject {
func loadUsers() async {
let url = URL(string: "https://api.example.com/users")!
let (data, _) = try await URLSession.shared.data(from: url)
}
}
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:
func loadData() async {
do {
users = try await service.fetchUsers()
} catch {
errorMessage = error.localizedDescription
}
}
func loadData() async {
do {
users = try await service.fetchUsers()
} catch is CancellationError {
return
} catch {
errorMessage = error.localizedDescription
}
}
Core Patterns
Minimal ViewModel Template
@MainActor
final class FeatureViewModel: ObservableObject {
@Published private(set) var items: [Item] = []
@Published private(set) var isLoading = false
@Published var error: Error?
private let service: ServiceProtocol
init(service: ServiceProtocol = Service()) {
self.service = service
}
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)
}
}
}
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 UserServiceProtocol {
func fetchUser(id: String) async throws -> User
func updateUser(_ user: User) async throws -> User
}
final class UserService: UserServiceProtocol {
private let client: NetworkClient
func fetchUser(id: String) async throws -> User {
try await client.request(.user(id: id))
}
}
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 {
mockService.stubbedUser = User.mock(name: "John")
await sut.loadUser(id: "123")
XCTAssertEqual(sut.user?.name, "John")
XCTAssertFalse(sut.isLoading)
XCTAssertNil(sut.error)
}
func test_loadUser_failure_setsError() async {
mockService.fetchError = NetworkError.noConnection
await sut.loadUser(id: "123")
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
class UserViewModel: ObservableObject {
init(service: UserServiceProtocol = UserService()) {
self.service = service
}
}
Complex: Factory Container
@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