4 KiB
ADR-005: Adoption of Swift Observation Framework
Status: Accepted
Date: 2025-01-11
Context:
After migrating services and repositories from Combine to async/await (ADR-004), the UI layer still used Combine's ObservableObject protocol with @Published properties for view-viewmodel binding. Apple introduced the Observation framework with the @Observable macro as a modern replacement.
Key considerations:
- Simpler syntax: @Observable eliminates boilerplate (@Published, objectWillChange)
- Better performance: Fine-grained observation tracks only accessed properties
- Reduced dependencies: No need for Combine in view models
- Language-level support: @Observable is a Swift macro, not a framework feature
- SwiftUI integration: Native support in SwiftUI for @Observable types
Decision:
I migrated all view models from ObservableObject + @Published to @Observable. This completed the removal of Combine from the codebase.
Migration Pattern:
Before (Combine):
final class StatusViewModel: ObservableObject {
@Published var statuses: [Status] = []
@Published var isLoading = false
private var cancellables = Set<AnyCancellable>()
func loadStatuses() {
isLoading = true
repository.fetchStatuses()
.sink { [weak self] completion in
self?.isLoading = false
} receiveValue: { [weak self] statuses in
self?.statuses = statuses
}
.store(in: &cancellables)
}
}
After (Observation):
@Observable
final class StatusViewModel {
var statuses: [Status] = []
var isLoading = false
func loadStatuses() async {
isLoading = true
defer { isLoading = false }
do {
statuses = try await repository.fetchStatuses()
} catch {
// handle error
}
}
}
Use of @ObservationIgnored:
Properties marked with @ObservationIgnored don't trigger view updates:
- Dependencies (repositories, services) injected via initializer
- Computed properties
- Internal state that doesn't affect UI
- Constants and configuration
Consequences:
Positive
- Less boilerplate: No @Published, no Combine imports, no AnyCancellable storage
- Better performance: SwiftUI only observes accessed properties, not the entire object
- Clearer code: Direct property access instead of publisher chains
- Natural async integration: Works seamlessly with async/await in repositories
- Compile-time safety: @Observable provides type-safe observation
- Removed Combine dependency: UI layer no longer needs Combine framework
Negative
- Migration effort: Requires rewriting all view models
- Learning curve: Understanding when to use @ObservationIgnored
- Breaking changes: View models are no longer ObservableObject instances
Neutral
- Different patterns: Some Combine patterns (like debouncing) need alternative implementations
- Observation scope: Need to understand observation tracking to avoid over-observation
Migration Strategy:
The migration was done incrementally:
- Started with simpler view models with fewer properties
- Replaced ObservableObject conformance with @Observable macro
- Removed @Published from properties
- Converted Combine publisher chains to async/await calls
- Removed AnyCancellable storage
- Added @ObservationIgnored to dependencies and computed properties
- Updated views to work with @Observable (mostly automatic)
Related Decisions:
- ADR-004: Migration from Combine to Async/Await - Repositories already using async/await enabled smooth integration
- ADR-002: Layered Architecture and Dependency Direction - ViewModels remain in UI layer with same responsibilities
Notes:
This migration completed the transition away from Combine throughout the entire codebase. The Observation framework provides a modern, performant, and simpler approach to reactive UI updates in SwiftUI.