top of page

Why use recoverable editing workflows in our code?

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

@MainActor
@Observable
final class EntryEditorViewModel {
    private let editor: WeightEntryManager
    private let entry: WeightEntry?

    var draft: WeightEntryDraft
    var errorMessage: String?
    private(set) var isSaving = false

    init(
        editor: WeightEntryManager = AppModel.shared.weightEntries,
        entry: WeightEntry? = nil
    ) {
        self.editor = editor
        self.entry = entry
        draft = editor.makeWeightEntryDraft(editing: entry)
    }

    var title: String { entry == nil ? "Log Weight" : "Edit Entry" }
    var unit: WeightUnit { editor.selectedWeightUnit }
    var inputStyle: WeightInputStyle { editor.weightInputStyle }
    func useKeyboard() { editor.setWeightInputStyle(.keyboard) }
    var latestPermittedEntryDate: Date { editor.latestPermittedEntryDate }
    var canSave: Bool { !draft.value.isEmpty && !isSaving }

    func save() async -> Bool {
        isSaving = true
        defer { isSaving = false }
        do {
            try await editor.save(draft, editing: entry)
            return true
        } catch {
            errorMessage = error.localizedDescription
            return false
        }
    }
}

This is the complete dedicated weight-entry editor ViewModel from Trend. A person types a measurement into a draft, asks to save it, and receives either success or an error. The draft, error message and saving state are visible here rather than hidden in a button closure.

The central promise is simple: a failed save must not be presented as a saved measurement. The person should still have their input and understand that it needs attention.

1. The approach

Code from Trend • Full Xcode project ↗
struct WeightEntryDraft: Sendable {
    var date: Date
    var value = ""
    var note = ""

    func kilograms(using unit: WeightUnit) throws -> Double {
        try unit.kilograms(from: value)
    }
}

The editor holds this value while the person types. Its text can be incomplete or invalid. kilograms(using:) delegates conversion and validation to the selected unit instead of treating nonempty text as a valid measurement.

The older source comment describes the draft as validated input, but the declaration itself makes no such guarantee: value is an editable String. That is the distinction to retain in your own code. A draft is what the person is working on; confirmed domain data is what the application has accepted and persisted.

CFA’s documented rationale: presentation owns draft state and user-facing outcomes; the feature owns the business decision and persistence workflow. A dedicated editor can deliberately offer Save and Cancel. An inline row follows a different contract: valid edits persist directly, with a policy for rapid changes and failure.

2. The problem it solves

Code from Trend • Full Xcode project ↗
func kilograms(from text: String) throws -> Double {
    guard let number = Double(text.replacingOccurrences(of: ",", with: ".")), number > 0 else {
        throw WeightValidationError.invalidWeight(self)
    }
    let kilograms = self == .kilograms ? number : number * 0.45359237
    guard (20...Self.maximumKilograms).contains(kilograms) else {
        throw WeightValidationError.invalidWeight(self)
    }
    return kilograms
}

This method checks that input can be parsed, converts pounds to canonical kilograms when necessary, and enforces the permitted range. A nonempty value such as “not a number” still fails. The ViewModel’s canSave property is therefore a presentation convenience, not the authoritative validation rule.

If validation existed only in a button’s disabled state, another caller could bypass it. If a save failure simply cleared the draft and dismissed the editor, the person would lose both their input and their chance to retry. Neither problem is solved by making the button look busy.

There is no force unwrap of the parsed number. Invalid input is an expected outcome to explain, not an unexpected reason to terminate the application.

3. Why it works

Code from Trend • Full Xcode project ↗
func save() async -> Bool {
    isSaving = true
    defer { isSaving = false }
    do {
        try await editor.save(draft, editing: entry)
        return true
    } catch {
        errorMessage = error.localizedDescription
        return false
    }
}

Follow the complete save method. It marks saving active and uses defer to restore that flag on either outcome. It awaits the feature. Only a successful return produces true; an error is recorded and produces false. The caller can keep the editor open on failure, and the draft is not cleared.

Code from Trend • Full Xcode project ↗
private func commit(_ candidate: WeightStore) async throws {
    let sorted = WeightStore(
        entries: candidate.entries.sorted { $0.date > $1.date },
        goalKilograms: candidate.goalKilograms
    )
    try await repository.save(sorted)
    publish(sorted)
    state = .ready
}

The weight log’s commit method shows the other half of that promise. It awaits the repository save before publishing the candidate and setting readiness. A failed write is not published as confirmed history. Swift’s error-handling model makes that ordering explicit.

These are useful behaviours from older source, not a complete current CFA editor blueprint. The original ViewModel accepts an entry as a second initializer input, and its async save does not retain a task to coalesce repeated callers. Current CFA routes screen context through the feature and gives an unstructured screen operation an owned handle. Those are separate improvements; they must preserve the failure behaviour shown here.

4. Advantages of using it

Code from Trend • Full Xcode project ↗
@Test func repositoryFailureIsPresentedAndDoesNotDismiss() async {
    let repository = InMemoryWeightRepository(saveError: .saveFailed)
    let appModel = TestAppModelFactory.make(repository: repository)
    let viewModel = EntryEditorViewModel(editor: appModel.weightEntries)
    viewModel.draft.value = "75"

    let didSave = await viewModel.save()

    #expect(!didSave)
    #expect(viewModel.errorMessage == "The test repository could not save.")
}

The test arranges a real feature with a failing repository, enters a value and requests save. It expects a failed outcome and a useful message. A stronger regression test should also assert that the entered text remains available and that a later successful retry produces only the intended record.

Code from Trend • Full Xcode project ↗
@Test func invalidWeightShowsValidationAndDoesNotDismiss() async {
    let appModel = TestAppModelFactory.make()
    let viewModel = EntryEditorViewModel(editor: appModel.weightEntries)
    viewModel.draft.value = "not a number"

    let didSave = await viewModel.save()

    #expect(!didSave)
    #expect(viewModel.errorMessage == "Enter a weight between 20 and 500 kg.")
    #expect(!viewModel.isSaving)
}

This second test distinguishes validation from storage failure. Invalid text also keeps the save unsuccessful and clears the saving indicator. The feature’s error becomes the screen’s explanation without inventing success.

Editorial trade-off: dedicated editors can keep input stable during one save; responsive inline editing needs more. Queue distinct edits or deliberately coalesce to the latest pending value, and do not discard changed input as a duplicate tap. A durable save may need to outlive navigation. Keep those policies explicit instead of assuming isSaving alone protects every caller.

The benefit is trust: what the screen calls saved matches what the persistence operation confirmed, and failure leaves the person with a route forward.

Try this with the CFA AI Toolkit for iOS:

Using CFA for iOS, preserve this editor’s latest input and make persistence failures recoverable.

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