top of page

CFA Reference: Decision Rules

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

Use this page when you know what you want to do but not where it goes. Each rule is drawn from the CFA specification, coding guide or change gate, and links to the page that explains it.

Definitions

  • Decision rule: A short “when X, do Y” statement that settles a common design choice.
  • Owner: The one type allowed to change a piece of state.
  • Overlapping-call policy: The Feature's rule for two callers of the same operation: share execution, or queue execution.

Rules

  1. Apply the most specific rule first; if two rules seem to conflict, the CFA specification is the canonical source.
  2. Never resolve a decision by adding a second owner of state.
  3. Record any product-required exception to a default rule in the change notes.

Decision rules

  • Where code goes. When logic decides what the screen shows, put it in the ViewModel. When it decides what is true for the product, put it in a Feature. When it decides how data is stored, put it in a repository.
  • New screen. When you add a unique screen, give it its own private ViewModel, constructed inline with @State, and a focused ViewModel test suite.
  • Detail screen. When a detail screen needs the selected item, pass the selected value data into the View and let it build its own ViewModel.
  • New capability. When behaviour is a real business capability, create a concrete Feature with a Feature suffix; do not create a Feature just to match a tab.
  • Shared data. When two screens need the same data, both ViewModels use the same Feature from AppModel.shared.
  • Launch work. When data should load at launch, give the Feature a synchronous startup method and call it from applicationDidFinishLaunching().
  • Screen work. When a screen has its own initial async work, start it from that screen's .task, never as the app's launch mechanism.
  • Overlapping refresh. When the same refresh can be requested twice, share one retained in-flight Task<Void, Never> and clear it in defer.
  • Every edit must run. When distinct commands, such as saves, must all execute, queue them in admission order; never drop a distinct edit as a duplicate.
  • Repeated button press. When a Button triggers async work, the ViewModel retains one task, returns it from its method, and reuses it on repeated presses.
  • Remote snapshot. When the data can be downloaded again, store it in one JSON cache.
  • User data. When local data is structured, queryable, growing or relational, use SwiftData through SwiftDataManager and a @ModelActor repository.
  • Storage failure. When storage cannot open, show the affected feature's request error and keep other screens usable; never gate the root UI.
  • Heavy work. When work is CPU-heavy or blocking, run it off the main actor with Sendable inputs and publish the result on the main actor.
  • Compiler data-race error. When Swift reports a data race, change ownership or isolation; never add @unchecked Sendable to silence it.
  • Naming. When naming a ViewModel method, name the work: refresh(), save(), retry().
  • Legacy concurrency. When an app uses GCD, operation queues or callbacks, upgrade the whole app in one move and pass the change gate for the whole app.
  • Review only. When nothing should change, use CFA Architecture Review.
  • Design. When the product is established, preserve its design; apply new-app design defaults only in new-product development.

Example

The decision “overlapping refresh → share one task” in compiled form:

Compiled example (excerpt; HabitsRepository is in Reference 6)

import Foundation
import Observation

// 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).

Common mistakes

  • Choosing a rule by habit from another architecture, such as adding a coordinator or a protocol per Feature.
  • Using reject-while-busy or latest-request-wins without a documented product reason.
  • Treating a design default, such as the Settings footer, as an architecture rule in an established app.

Links

bottom of page