top of page

CFA Reference: Tutorial: Upgrading a Whole GCD App in One Move

Formal CFA AI Toolkit for iOS Documentation · Reference 24 of 26

This tutorial upgrades a whole app in one move. Every feature is moved, every replaced GCD route is removed, and the change is complete only when the whole-app completion record exists. Nothing is released in between.

Definitions

  • Behaviour contract: The written list of behaviour, stored data, error delivery, ordering and cancellation policy that must be preserved.
  • Concurrency inventory: Every GCD, callback, OperationQueue, timer, lock or delegate route, marked migrated or retained with a reason.
  • Completion record: The evidence the change gate requires before the word “complete” is used.

Rules

  1. Commit existing work. Commit unrelated changes first, as project-workflow.md requires.
  2. Record the workflow. Add CFA workflow: established-product maintenance to AGENTS.md, so the design is preserved.
  3. Baseline. Run node scripts/project-analysis/project-analysis.mjs <project-root> --out <new-analysis.json>, and run CFA Architecture Review for the Xcode Project Dashboard report.
  4. Inventory. List every queue, barrier, group, semaphore, lock and callback.
  5. Behaviour contract. Write it from tests, source and manual journeys.
  6. Ask for the whole upgrade. For example: “Using CFA, migrate this legacy GCD codebase to Swift Concurrency. Upgrade every feature in this one change.”
  7. Check each feature's new shape. A concrete @MainActor Feature, an actor or @ModelActor repository, Sendable domain values, a private ViewModel per screen.
  8. Check removals. Search for DispatchQueue, OperationQueue, DispatchGroup, semaphores, locks and @unchecked Sendable; each must be gone or recorded with a reason.
  9. Build and test. Build with Swift 6 language mode, run the tests and the manual behaviour regression at the final commit.
  10. Write the completion record for the whole app and do your human architecture review. Only then call the upgrade complete.

Decision rules

  • When the assistant suggests moving one feature and releasing, keep the change open until every feature is upgraded.
  • When a callback API has a native async replacement, use it; bridge only when none exists.
  • When an error appears in Swift 6 mode, fix ownership (Reference 18), never with an escape hatch.
  • When any completion item is missing, the upgrade is not complete.

Example

Before: a callback store with a private serial queue and a hop to the main queue.

Before (compiled; prohibited in CFA, shown as the starting point)

import Foundation

// BEFORE: a legacy callback API (shown only as the starting point to remove)
final class LegacyHabitStore: @unchecked Sendable {
    private let queue = DispatchQueue(label: "habits")
    private var names: [String] = []

    func loadNames(completion: @escaping @Sendable ([String]) -> Void) {
        queue.async {
            let snapshot = self.names
            DispatchQueue.main.async { completion(snapshot) }
        }
    }
}

After: the same responsibility as an actor repository, a Feature and a ViewModel.

After (compiled excerpt; Habit is in Reference 5)

import Foundation
import Observation

// MARK: Repository contract in domain language
protocol HabitsRepository: Sendable {
    func fetchHabits() async throws -> [Habit]
    func insertHabit(_ habit: Habit) async throws
}

// MARK: Actor-isolated implementation
actor LocalHabitsRepository: HabitsRepository {
    private var storedHabits: [Habit] = []

    func fetchHabits() async throws -> [Habit] {
        storedHabits
    }

    func insertHabit(_ habit: Habit) async throws {
        storedHabits.append(habit)
    }
}

// MARK: Feature: the authoritative owner of habits
@MainActor
@Observable
final class HabitsFeature {
    @ObservationIgnored private var refreshTask: Task<Void, Never>?
    private let repository: any HabitsRepository
    private(set) var habits: [Habit] = []
    private(set) var isLoading = false
    private(set) var loadError: String?

    init(repository: any HabitsRepository) {
        self.repository = repository
    }

    /// Synchronous launch entry point: initiates work and returns.
    func startLoading() {
        _ = sharedRefreshTask()
    }

    /// Every caller awaits the one in-flight refresh.
    func refresh() async {
        await sharedRefreshTask().value
    }

    private func sharedRefreshTask() -> Task<Void, Never> {
        if let refreshTask { return refreshTask }
        let newRefreshTask = Task { [repository] in
            await self.performRefresh(using: repository)
        }
        refreshTask = newRefreshTask
        return newRefreshTask
    }

    private func performRefresh(using repository: any HabitsRepository) async {
        defer { refreshTask = nil }
        isLoading = true
        defer { isLoading = false }
        do {
            habits = try await repository.fetchHabits()
            loadError = nil
        } catch {
            loadError = error.localizedDescription
        }
    }

    func addHabit(named name: String) async throws {
        let cleanedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !cleanedName.isEmpty else { throw HabitError.invalidName }
        let habit = Habit(id: UUID(), name: cleanedName)
        try await repository.insertHabit(habit)
        habits.append(habit)
    }
}

Compiled with Swift 6 language mode and warnings treated as errors: type-checked on Swift 6.2 and built on Swift 6.4 (Linux toolchains, Foundation and Observation only).

Note on the repository text. The toolkit's migration skill and change gate describe moving one feature at a time and “Continuing migration” checkpoints. This documentation follows a stricter policy: the whole app is upgraded in one change and no partly upgraded app is released.

Common mistakes

  • Releasing after the first feature is upgraded.
  • Keeping DispatchQueue calls inside new async methods.
  • Rewriting syntax without moving ownership to Features.
  • Changing the app's design during the upgrade.

Links

bottom of page