Triton/Documentation/ADR/ADR-005-adoption-of-swift-observation-framework.md
Otávio 3e878667a1 Add Triton App
Signed-off-by: Otavio Cordeiro <otaviocc@users.noreply.github.com>
2025-12-15 20:39:07 +01:00

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:

  1. Simpler syntax: @Observable eliminates boilerplate (@Published, objectWillChange)
  2. Better performance: Fine-grained observation tracks only accessed properties
  3. Reduced dependencies: No need for Combine in view models
  4. Language-level support: @Observable is a Swift macro, not a framework feature
  5. 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:

  1. Started with simpler view models with fewer properties
  2. Replaced ObservableObject conformance with @Observable macro
  3. Removed @Published from properties
  4. Converted Combine publisher chains to async/await calls
  5. Removed AnyCancellable storage
  6. Added @ObservationIgnored to dependencies and computed properties
  7. Updated views to work with @Observable (mostly automatic)

Related Decisions:

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.