SwiftUI Architecture Patterns
After building several production SwiftUI apps, I've developed strong opinions about what works and what doesn't. This post shares the patterns I reach for consistently.
The Basics: MVVM with a Twist
Classic MVVM works well with SwiftUI, but I've made some modifications:
View Models as @Observable Classes
With iOS 17's `@Observable` macro, view models became much cleaner:
@Observable
final class ProductListViewModel {
var products: [Product] = []
var isLoading = false
var error: Error?
private let repository: ProductRepository
func loadProducts() async {
isLoading = true
defer { isLoading = false }
do {
products = try await repository.fetchProducts()
} catch {
self.error = error
}
}
}Dependency Injection via Environment
Rather than passing dependencies through initializers, I use SwiftUI's environment:
@Environment(ProductRepository.self) private var repositoryThis keeps views clean and makes testing straightforward.
Navigation: The Coordinator Pattern
Navigation state in SwiftUI can become messy. I use a coordinator pattern with `NavigationStack`:
@Observable
final class AppCoordinator {
var path = NavigationPath()
func navigateToProduct(_ id: Product.ID) {
path.append(Route.product(id))
}
func popToRoot() {
path.removeLast(path.count)
}
}Data Flow: Unidirectional When It Matters
For complex features, unidirectional data flow (inspired by The Composable Architecture) prevents bugs:
For simple CRUD, this is overkill. Know when to use it.
Testing Strategy
The key insight: test behavior, not implementation.