CFA Reference: Features and Ownership
Formal CFA AI Toolkit for iOS Documentation · Reference 5 of 26
A Feature is the single authoritative owner of one area of the product. Everything else reads its state or asks it to do work.
Definitions
- Concrete Feature: A final class marked @MainActor and @Observable, named with a Feature suffix.
- Domain data: The values the product is about, for example habits: [Habit].
- Operation state: Whether work is in progress, for example isLoading: Bool.
- Error state: The most recent failure for the user, for example loadError: String?.
- Shared in-flight task: A retained Task<Void, Never> that every caller awaits, cleared in defer when the work ends.
Rules
- A Feature is concrete. There is no matching FeatureAPI protocol, and a Feature is never called a Manager (change gate rule 7).
- Keep domain data, operation state and errors as separate values. Do not use a result-like LoadState enum that carries them together (rule 8).
- Clear a shared in-flight Task<Void, Never> in defer (rule 9).
- Mark the Feature @MainActor explicitly. Mark the task handle @ObservationIgnored so it does not trigger view updates.
- Expose state as private(set) and change it only through the Feature's own methods.
- Provide a synchronous startLoading() for launch and an async method, such as refresh() or reload(), for callers that need to wait.
Decision rules
- When a second screen needs the same data, read the existing Feature; do not create another owner.
- When a refresh may be requested twice at once, return the existing in-flight task instead of starting a second one.
- When a value must be validated, validate it in the Feature and throw a domain error.
- When the Feature needs storage, depend on a repository protocol passed in through the initialiser.
Example
The compiled HabitsFeature below keeps habits, isLoading and loadError separate, shares one refresh task between callers, and clears it in defer. It follows the shape of the repository's Habit Tracker HabitsFeature.
Compiled example (excerpt)
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: 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)
}
}
// HabitsRepository is defined in Reference 6.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
- Writing a HabitsLoadState enum with cases such as .loading, .loaded([Habit]) and .failed(String). It breaks rule 8 and loses the old data while reloading.
- Naming the type HabitsManager or adding a HabitsFeatureProtocol.
- Forgetting to clear the in-flight task, so a later refresh returns a finished task and never reloads.
- Letting two Features both store the same collection.
