CFA Reference: Tutorial: A New App from the CFA App Template
Formal CFA AI Toolkit for iOS Documentation · Reference 23 of 26
This tutorial takes you from an installed template to a new feature that passes the change gate. It uses only steps and names from the repository.
Definitions
- Product name: The name you type in Xcode; it replaces ___PACKAGENAME:identifier___ in the template.
- Workflow record: A line in the project's AGENTS.md: CFA workflow: new-product development.
Rules
- Install the template. Run Install Xcode Template.command and wait for “CFA App is installed in Xcode”. Quit and reopen Xcode.
- Create the project. File → New → Project → iOS → CFA App. Enter a product name and organisation identifier.
- Run it. Choose an iPhone simulator and run. You should see the tab bar, the Git Repos list loading from the network, and Settings with colour themes.
- Read the route. Open GitReposView → GitReposViewModel → AppModel.shared.gitReposFeature → GitHubAPI and RepoFileCache. Note that CFAAppDelegate starts gitReposFeature.startLoading() at launch.
- Commit. Commit the new project before asking an assistant for changes, so its work is a separate, reviewable change.
- Record the workflow. Add CFA workflow: new-product development to AGENTS.md.
- Install the skills. Run node scripts/install.mjs (Codex) or node scripts/install.mjs --mode skills --skills-directory ~/.claude/skills (Claude Code).
- Ask for a feature. For example: “Using CFA, add a Habits tab where I can add and list habits.”
- Check the result against the gate. Expect HabitsView, a private HabitsViewModel, a concrete HabitsFeature in AppModel.live(), a repository protocol, HabitsViewModelTests and HabitsFeatureTests.
- Build and test. Build and run the tests at the final commit, then review the navigator map yourself.
Decision rules
- When the assistant adds a protocol for the Feature, ask it to remove it (rule 7).
- When it stores a ViewModel on AppModel, ask it to construct the ViewModel inline in the View (rule 1).
- When it adds launch work to RootTabView, move it to a Feature startup call (rules 6 and 9).
- When it uses a LoadState enum for data and errors, split them (rule 8).
Example
After step 8, the new Feature, AppModel and ViewModel should have this shape. This is the compiled reference code used throughout this documentation:
Compiled example: complete file
import Foundation
import Observation
// MARK: Domain value owned by the Habits feature
struct Habit: Sendable, Identifiable, Equatable {
let id: UUID
var name: String
}
enum HabitError: Error, Equatable {
case invalidName
}
// 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)
}
}
// MARK: Settings feature, present in every new CFA app
@MainActor
@Observable
final class SettingsFeature {
private let defaults: UserDefaults
private(set) var prefersCompactList: Bool
init(defaults: UserDefaults) {
self.defaults = defaults
prefersCompactList = defaults.bool(forKey: "prefersCompactList")
}
func updatePrefersCompactList(_ newValue: Bool) {
defaults.set(newValue, forKey: "prefersCompactList")
prefersCompactList = newValue
}
}
// MARK: Composition root
@MainActor
@Observable
final class AppModel {
static let shared = live()
let habitsFeature: HabitsFeature
let settingsFeature: SettingsFeature
private var didFinishLaunching: Bool
init(habitsFeature: HabitsFeature, settingsFeature: SettingsFeature) {
self.habitsFeature = habitsFeature
self.settingsFeature = settingsFeature
didFinishLaunching = false
}
static func live() -> AppModel {
let habitsRepository = LocalHabitsRepository()
return AppModel(
habitsFeature: HabitsFeature(repository: habitsRepository),
settingsFeature: SettingsFeature(defaults: .standard)
)
}
func applicationDidFinishLaunching() {
guard !didFinishLaunching else { return }
didFinishLaunching = true
habitsFeature.startLoading()
}
}
// MARK: Screen ViewModel
@MainActor
@Observable
final class HabitsViewModel {
private let habitsFeature: HabitsFeature
@ObservationIgnored private var refreshTask: Task<Void, Never>?
var draftName = ""
private(set) var saveError: String?
init(habitsFeature: HabitsFeature = AppModel.shared.habitsFeature) {
self.habitsFeature = habitsFeature
}
var habitNames: [String] { habitsFeature.habits.map(\.name) }
var isLoading: Bool { habitsFeature.isLoading }
var loadError: String? { habitsFeature.loadError }
/// Repeated presses reuse the one retained task.
@discardableResult
func refresh() -> Task<Void, Never> {
if let refreshTask { return refreshTask }
let habitsFeature = habitsFeature
let newRefreshTask = Task {
await habitsFeature.refresh()
self.refreshTask = nil
}
refreshTask = newRefreshTask
return newRefreshTask
}
func save() async {
do {
try await habitsFeature.addHabit(named: draftName)
draftName = ""
saveError = nil
} catch {
saveError = "The habit could not be saved."
}
}
}
// MARK: Test-style isolated graph (never touches AppModel.shared)
@MainActor
func makeIsolatedAppModel() -> AppModel {
AppModel(
habitsFeature: HabitsFeature(repository: LocalHabitsRepository()),
settingsFeature: SettingsFeature(defaults: UserDefaults(suiteName: "tests") ?? .standard)
)
}
@MainActor
func checkSavedHabitIsPublished() async throws -> Bool {
let appModel = makeIsolatedAppModel()
try await appModel.habitsFeature.addHabit(named: " Read ")
return appModel.habitsFeature.habits.map(\.name) == ["Read"]
}
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). It was also built as an executable on 6.4 and run: a saved habit was published and overlapping refreshes completed with isLoading false and no error.
Common mistakes
- Skipping the first commit, so the assistant's changes mix with template files.
- Accepting the change because it builds, without the tests and the review.
- Asking for several features in one request.
