top of page

Why use a UI and model boundary in our code?

Code from Trend • Full Xcode project ↗
import Foundation
import Observation

@MainActor
@Observable
final class ProgressViewModel {
    private let progress: ProgressManager

    init(progress: ProgressManager = AppModel.shared.progressFeature) {
        self.progress = progress
    }

    var snapshot: WeightHistoryData { progress.progressSnapshot }
    var goalKilograms: Double? { progress.goalWeightKilograms }
    var unit: WeightUnit { progress.selectedWeightUnit }
    var isLoading: Bool { progress.isPreparingProgress }
    var range: ProgressRange {
        get { progress.selectedProgressRange }
        set { progress.selectProgressRange(newValue) }
    }
}

This is the complete ViewModel for the progress screen in Trend, a weight-tracking app. The screen needs chart data, a weight unit, a loading indication and a selected period. It receives all four through this object. The ViewModel does not load measurements or calculate a projection itself.

The important question is not whether we have named a file “ViewModel”. It is whether the program’s decisions remain usable without that screen. Trend answers it by placing progress behaviour in the model and giving presentation a narrow connection to it.

1. The approach

Code from Trend • Full Xcode project ↗
import Foundation

/// Presents the progress feature as a complete capability without exposing the
/// managers that calculate it or the stores that supply its inputs.
@MainActor
final class ProgressManager {
    private let progress: ProgressTracker
    private let weightLog: WeightLogManager
    private let settings: UserSettingsStore

    init(
        progress: ProgressTracker,
        weightLog: WeightLogManager,
        settings: UserSettingsStore
    ) {
        self.progress = progress
        self.weightLog = weightLog
        self.settings = settings
    }

    var progressSnapshot: WeightHistoryData { progress.snapshot }
    var goalWeightKilograms: Double? { weightLog.goalKilograms }
    var selectedWeightUnit: WeightUnit { settings.unit }
    var isPreparingProgress: Bool { progress.isLoading }
    var selectedProgressRange: ProgressRange { progress.range }

    func selectProgressRange(_ range: ProgressRange) {
        progress.select(
            range,
            entries: weightLog.entries,
            goalKilograms: weightLog.goalKilograms
        )
    }
}

This complete class is the feature behind the opening ViewModel. weightLog holds the measurement history, settings supplies the selected unit, and progress prepares the chart’s results. They are private dependencies. The feature exposes the information and operation its screen actually needs.

Read selectProgressRange as a model command: prepare progress for this range, using the current entries and goal. The caller does not need to know how those collaborators fit together. This older source calls the feature ProgressManager; current CFA naming would use ProgressFeature. The ownership decision is the same.

2. The problem it solves

Code from Trend • Full Xcode project ↗
Picker("Period", selection: $viewModel.range) {
    ForEach(ProgressRange.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)

This control lives in Trend’s ProjectionView. It presents the available periods and changes the ViewModel’s range binding. That is a presentation job: decide where the picker goes and how the person interacts with it.

If the picker also filtered measurements and calculated a projection, a second interface would have to copy that work. A future Mac screen could disagree with the iPhone screen about the same recorded history. Moving the calculation into another View helper would only move the problem around the presentation layer.

CFA’s documented rationale: shared business meaning belongs below the screen. The ViewModel may own selection, draft text and messages, but a decision that another interface must reproduce belongs in the owning feature. Here the range is already owned by the tracker, so the ViewModel exposes it rather than maintaining a second stored copy.

3. Why it works

Code from Trend • Full Xcode project ↗
func selectProgressRange(_ range: ProgressRange) {
    progress.select(
        range,
        entries: weightLog.entries,
        goalKilograms: weightLog.goalKilograms
    )
}

The picker writes the ViewModel’s setter. That setter calls this feature command. The feature supplies the tracker with authoritative measurements and the goal, then the screen reads the resulting snapshot through the same connection. Each step has a distinct responsibility; there is no second chart calculation hidden in the ViewModel.

These computed properties are deliberately small. They expose current information rather than launching expensive work from a getter. The observable dependencies in Trend’s model provide change tracking; @Observable on the ViewModel is not a replacement for correctly observing the underlying state. Apple’s model-data guidance explains how SwiftUI tracks the observable values a view reads.

Code from Trend • Full Xcode project ↗
TrendChartView(data: TrendChartData(
    snapshot: viewModel.snapshot,
    goalKilograms: viewModel.goalKilograms,
    unit: viewModel.unit
))

The chart receives a prepared value. Passing drawing data into a reusable chart is different from passing a live feature or ViewModel into a screen initializer. The chart describes the picture; it does not acquire ownership of the weight log.

4. Advantages of using it

Code from Trend • Full Xcode project ↗
@Test func exposesSnapshotGoalUnitAndLoadingState() async {
    let now = Date()
    let entries = [
        WeightEntry(date: now.addingTimeInterval(-86_400), kilograms: 82),
        WeightEntry(date: now, kilograms: 80)
    ]
    let repository = InMemoryWeightRepository(store: .init(entries: entries, goalKilograms: 75))
    let appModel = TestAppModelFactory.make(repository: repository, unit: .pounds)
    await appModel.weightEntries.refresh()
    let viewModel = ProgressViewModel(progress: appModel.progressFeature)

    #expect(viewModel.snapshot.points.map(\.kilograms) == [82, 80])
    #expect(viewModel.snapshot.changeKilograms == -2)
    #expect(viewModel.goalKilograms == 75)
    #expect(viewModel.unit == .pounds)
    #expect(!viewModel.isLoading)
}

This test creates real progress behaviour with an in-memory weight repository, refreshes it, and connects the real ViewModel. It checks measurements, change, goal, unit and loading state without rendering a chart. That is possible because the screen is not where those results are calculated.

Editorial trade-off: a dedicated screen ViewModel can be small, as this one is. CFA keeps that explicit screen boundary even when a particular ViewModel mainly exposes feature data. It should not become an excuse to duplicate state or invent extra forwarding layers below the feature.

The practical advantage is a stable division of work. Change a chart’s appearance in presentation. Change how progress is prepared in the model. Test the outcome at the boundary where it matters. Neither change should require copying business rules into another screen.

Try this with the CFA AI Toolkit for iOS:

Using CFA for iOS, separate the business decisions from this screen’s presentation.

Scope: code from the linked app revisions; smaller excerpts belong to their containing types and targets. The articles distinguish older implementations from current CFA conventions. Source verification is not a full app build or device test. Suggested checks are guidance, not claims they ran against your application.

bottom of page