01 · CFA IN PRACTICE
“Using CFA for iOS, Restructure the Following Code”: Give Your AI an Architecture, Not Another Guess
A piece of code can look reassuring and still be putting responsibilities in the wrong place. It catches errors. It avoids a forced unwrap. It offers a retry button. There are comments explaining what happens. At first glance, it looks like thoughtful work.
Then the app grows. A second screen needs the same data. Settings should still work when the database is unavailable. A refresh needs to run without replacing the whole interface. You want to test a failed fetch without launching the app.
Suddenly, that helpful little block in the app entry point is making decisions for the entire product.
This is the problem behind a short prompt I gave AI:
“Using CFA tidy up and restructure the following code:”
The important word is not “tidy”. It is CFA. Without an agreed architecture, tidying can mean renaming a function, shortening a conditional or moving the same tangled responsibilities into a new file. With an architecture available to the agent, the request has a more useful meaning: identify who should own each responsibility, then put the code there.
In this walkthrough, we use the CFA AI Toolkit for iOS to reason about a SwiftUI and SwiftData example. We will keep its good intention—handling failure safely—while changing the structure that makes future work difficult.
🎯 By the end, you should be able to give AI a precise restructuring request, follow the resulting data flow, and tell the difference between cleaner-looking code and a genuinely clearer design.
02 · CFA IN PRACTICE
The starting point: sensible error handling, misplaced ownership
Here is the original example, with its formatting cleaned up. The app holds an optional SwiftData container, tries to create it in its initializer and decides which interface to show based on whether creation succeeded.
Swift · before.swift
import SwiftUI
import SwiftData
@main
struct MyApp: App {
@State private var container: ModelContainer?
@State private var loadError: Error?
init() {
do {
let schema = Schema([User.self, Item.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false
)
_container = State(initialValue: try ModelContainer(
for: schema,
configurations: [config]
))
} catch {
_loadError = State(initialValue: error)
}
}
var body: some Scene {
WindowGroup {
if let container = container {
ContentView()
.modelContainer(container)
} else {
DatabaseErrorView(error: loadError, onRetry: {
tryToReloadDatabase()
})
}
}
}
private func tryToReloadDatabase() {
do {
let schema = Schema([User.self, Item.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false
)
container = try ModelContainer(
for: schema,
configurations: [config]
)
loadError = nil
} catch {
loadError = error
}
}
}
There are good decisions here. A throwing operation is handled with do/catch. The user has a way to retry. A storage problem is not hidden behind try!. We should preserve those intentions.
But look at the jobs assigned to MyApp. It knows the database schema, constructs the storage configuration, opens the store, retains the failure, chooses the root interface and implements recovery. The same database setup appears twice.
The issue is not the number of lines. It is the number of reasons this one type will have to change.
A storage configuration change touches the app entry point. So does a revised error experience. So does a new retry policy. Because the normal interface is conditional on database availability, a storage failure also becomes an application-wide access decision.
The original code answers “How do I avoid crashing here?” CFA asks a second question: “Which part of the application should be responsible for this?”
This is an architectural judgement, not a claim that SwiftUI forbids the original pattern. Apple supports supplying a SwiftData container through the environment. CFA deliberately chooses a feature-owned persistence boundary for the applications we build with it. Apple’s ModelContainer documentation describes the underlying storage facility.
03 · CFA IN PRACTICE
What “using CFA” actually means
Cooperative Feature Architecture gives the application a visible division of responsibilities. SwiftUI Views describe the interface. Each feature screen has its own ViewModel. Concrete Features own application capabilities and shared behaviour. Lower-level storage and networking collaborators handle external systems.
AppModel assembles the live dependencies and exposes Features. It does not become a shared bucket for database objects, screen messages or loading flags.
The toolkit is not a replacement for SwiftUI, SwiftData or Swift Concurrency. It supplies architecture guidance and AI workflows around those technologies. You still own the code, the product decisions and the verification.
The agent must have access to the current toolkit instructions. Merely typing three letters into an assistant that has never read those instructions does not establish a shared standard. Before asking for a restructuring, make the toolkit available through your supported integration or repository instructions, and ask the agent to read the applicable specification and skill.
That is what makes the short prompt practical: the detailed requirements live somewhere durable, instead of being reinvented in every conversation.
Explore the CFA AI Toolkit for iOS and its installation instructions.
04 · CFA IN PRACTICE
Change the question from “Did the app launch?” to “Did this request succeed?”
Imagine the data represents a list of conversations. If SwiftData cannot open its store, the user has not failed to launch the application. A request to retrieve conversations has failed.
That distinction changes the design. The Chats screen can explain that it could not load conversations and offer a retry. Settings, help and any genuinely independent capabilities can remain usable. Features that require the unavailable data still fail honestly; separating the interface does not make a broken database healthy.
For our Items example, the request travels down through these owners:
ItemsView
↓ reports user intent
ItemsViewModel
↓ requests the capability
ItemsFeature
↓ asks for stored domain values
SwiftDataManager → ItemsRepository (@ModelActor)
↓ opens and queries the persistent store
SwiftData
Values or errors return through the same boundaries.
AppModel assembles the objects; it is not the data path.
A failed store-opening operation travels back through the same request as a failed fetch. The Feature retains the operation’s outcome. The ViewModel turns that outcome into appropriate screen wording. The View renders it.
We do not move container and loadError into ContentView and declare victory. That would change the file name while preserving the architectural problem.
05 · CFA IN PRACTICE
First, make the app entry point boring
The app entry point should make the initial structure obvious. Here it creates a window and presents a stable root. The root describes navigation and presents the feature screen.
Swift · App.swift
import SwiftUI
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self)
private var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
NavigationStack {
ItemsView()
}
}
}
There is no optional container, no database retry function and no switch between a normal application and a launch-error application. The app delegate bridge is the connection to the actual application lifecycle; we will look at it shortly.
There is an important nuance here: an if inside a SwiftUI View is not inherently imperative or bad architecture. Conditional rendering is a normal declarative technique. CFA’s restriction concerns the responsibility of this particular type. The app entry point declares the scene structure; a feature screen renders the state of its feature.
The following code is a focused Items walkthrough, not a complete new-app scaffold. It omits unrelated features and visual theming to keep the refactoring readable. A full new CFA app also includes SettingsView, SettingsViewModel and SettingsFeature, plus the project’s theme infrastructure. Do not remove those from an existing project to match this excerpt.
06 · CFA IN PRACTICE
Put database opening behind the storage request
Start with a small domain value and the one substitution boundary we actually need. ItemSummary is the information the feature uses, not a live SwiftData record. ItemsStore lets a real store and a deterministic test store provide the same operation.
Swift · ItemsStore.swift
import Foundation
struct ItemSummary: Identifiable, Sendable, Equatable {
let id: UUID
let title: String
}
protocol ItemsStore: Sendable {
func fetchItems() async throws -> [ItemSummary]
}
Then give SwiftData a named owner. The manager below opens the persistent store only when an operation needs it. The model actor owns the context used to fetch records and converts them into domain values before returning.
Swift · SwiftDataManager.swift
import Foundation
import SwiftData
actor SwiftDataManager: ItemsStore {
private var itemsRepository: ItemsRepository?
func fetchItems() async throws -> [ItemSummary] {
let repository = try openRepositoryIfNeeded()
return try await repository.fetchItems()
}
private func openRepositoryIfNeeded() throws -> ItemsRepository {
if let itemsRepository {
return itemsRepository
}
// Keep the existing model types and storage configuration.
let schema = Schema([User.self, Item.self])
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false
)
let container = try ModelContainer(
for: schema,
configurations: [configuration]
)
let repository = ItemsRepository(modelContainer: container)
itemsRepository = repository
return repository
}
}
@ModelActor
actor ItemsRepository {
func fetchItems() throws -> [ItemSummary] {
let request = FetchDescriptor<Item>(
sortBy: [SortDescriptor(\Item.title)]
)
return try modelContext.fetch(request).map { item in
ItemSummary(id: item.id, title: item.title)
}
}
}
The original prompt does not include the definitions of User or Item. This excerpt assumes the existing Item has an id: UUID and title: String. Adapt the mapping to your actual model. Do not replace a shipped schema with invented example entities.
Notice what happens if container construction throws: the manager has not cached a repository, so the request throws. A later request can attempt opening again. After opening succeeds, the same repository—and its container—is reused. An ordinary fetch failure does not automatically destroy or rebuild that container.
There is no suspension inside openRepositoryIfNeeded(). For this one manager instance, its actor isolation prevents another call from interleaving halfway through container creation. That is not a global lock across every manager someone might construct. The live graph must share this owner rather than creating one database manager per feature.
@ModelActor provides the repository’s isolated access to its model context. Keep its managed records within that boundary; returning immutable, Sendable values makes the interface explicit. Neither an actor nor an async keyword is a promise of a dedicated background thread. See Apple’s ModelActor documentation and Swift’s concurrency guide.
A refactoring must preserve data, not merely compile. Retain the existing store location, configuration name, model identities, relationships, migration plan, entitlements and CloudKit settings where applicable. This example moves the configuration shown in the prompt; it does not prove that configuration matches an unseen production app.
Do not “recover” by deleting the database or silently switching to an empty in-memory store. Those actions can disguise lost access to real user data as a successful load. Also remember that Swift error handling catches thrown errors, not every possible process crash.
07 · CFA IN PRACTICE
Give the operation a concrete Feature
The Feature owns the current items and the policy for refreshing them. It does not know how SwiftData opens a file. It knows that it can request items, keep the last successful result and represent a failed request honestly.
Swift · ItemsFeature.swift
import Foundation
import Observation
@MainActor
@Observable
final class ItemsFeature {
private let itemsStore: any ItemsStore
private(set) var items: [ItemSummary] = []
private(set) var isRefreshing = false
private(set) var hasLoaded = false
private(set) var refreshError: (any Error)?
init(itemsStore: any ItemsStore) {
self.itemsStore = itemsStore
}
func refresh() async {
// This read-only example ignores duplicate refresh requests.
guard !isRefreshing else { return }
isRefreshing = true
refreshError = nil
defer { isRefreshing = false }
do {
try Task.checkCancellation()
let fetchedItems = try await itemsStore.fetchItems()
try Task.checkCancellation()
items = fetchedItems
hasLoaded = true
} catch is CancellationError {
// Cancellation is not a storage failure.
} catch {
refreshError = error
}
}
}
The separation between items, isRefreshing, hasLoaded and refreshError is deliberate. A failed refresh does not erase previously loaded items. A successful empty result is distinguishable from a list we have never successfully loaded.
This example chooses a simple overlap policy: while one refresh is in progress, another refresh request returns without starting a second fetch. It does not wait for, or join, the existing request. That is suitable for this read-only demonstration; a different product contract might require callers to await one shared operation.
The cancellation checks prevent a cancelled operation from publishing a newly fetched result. They do not interrupt a synchronous SwiftData fetch halfway through execution. Cancellation is cooperative, so its meaning must be explicit rather than assumed.
For a feature that also edits or deletes records while refreshing, review the policy again. A fetched snapshot can become stale across an await. Actor isolation does not choose whether an old result should overwrite newer application state. That needs a deliberate rule and a test.
The type is named ItemsFeature, not ItemsManager. We reserve a name such as SwiftDataManager for the lower-level facility it manages. Names should tell readers whether they are looking at a product capability or infrastructure.
08 · CFA IN PRACTICE
The ViewModel prepares the screen; the View displays it
The Items screen receives a dedicated ViewModel. It retains the narrow Feature it needs, not the whole AppModel and not a database context.
Swift · ItemsViewModel.swift
import Observation
@MainActor
@Observable
final class ItemsViewModel {
private let itemsFeature: ItemsFeature
init(itemsFeature: ItemsFeature = AppModel.shared.itemsFeature) {
self.itemsFeature = itemsFeature
}
var items: [ItemSummary] { itemsFeature.items }
var isRefreshing: Bool { itemsFeature.isRefreshing }
var showsEmptyMessage: Bool {
itemsFeature.hasLoaded && itemsFeature.items.isEmpty
&& itemsFeature.refreshError == nil
&& !itemsFeature.isRefreshing
}
var errorMessage: String? {
guard itemsFeature.refreshError != nil else { return nil }
return "We couldn’t load your items. Please try again."
}
func refresh() async {
await itemsFeature.refresh()
}
}
Its initializer supplies the live Feature by default, while tests can pass an isolated concrete Feature. There is no database access in the initializer. Creating the screen does not secretly start work.
The ViewModel maps a technical failure to user-facing wording. The underlying error remains available on the Feature for deliberate diagnostic handling; the interface does not need to expose raw database paths or implementation details.
Now the View can describe loading feedback, an error with a retry action, a genuine empty result and the items themselves:
Swift · ItemsView.swift
import SwiftUI
struct ItemsView: View {
@State private var viewModel = ItemsViewModel()
var body: some View {
List {
if viewModel.isRefreshing {
ProgressView("Loading items…")
}
if let message = viewModel.errorMessage {
Section {
Text(message)
Button("Try again") {
Task { await viewModel.refresh() }
}
.disabled(viewModel.isRefreshing)
}
}
if viewModel.showsEmptyMessage {
Text("No items yet.")
}
ForEach(viewModel.items) { item in
Text(item.title)
}
}
.navigationTitle("Items")
}
}
The conditionals belong here: they describe this screen’s appearance. They do not open storage, select a schema or decide whether the rest of the app exists.
The retry button creates a Task to enter the asynchronous ViewModel method. This example deliberately allows that short shared-data refresh to finish if the screen disappears. If the screen needs to cancel or replace work, its ViewModel should own the Task handle and that policy. Do not store a Task handle in the View simply to hide a lifetime decision.
In a production interface, apply your theme and localisation conventions and check accessibility. Those presentation details should not require a change to the storage request.
09 · CFA IN PRACTICE
Assemble dependencies once, without turning AppModel into a dumping ground
AppModel.live() is the composition function: the one place where this production dependency chain is assembled. Its initializer only assigns the supplied Feature and initializes the launch-once guard.
Swift · AppModel.swift
@MainActor
final class AppModel {
static let shared = AppModel.live()
let itemsFeature: ItemsFeature
private var didFinishLaunching: Bool
init(itemsFeature: ItemsFeature) {
self.itemsFeature = itemsFeature
didFinishLaunching = false
}
static func live() -> AppModel {
let swiftDataManager = SwiftDataManager()
let itemsFeature = ItemsFeature(itemsStore: swiftDataManager)
return AppModel(itemsFeature: itemsFeature)
}
func applicationDidFinishLaunching() async {
guard !didFinishLaunching else { return }
didFinishLaunching = true
await itemsFeature.refresh()
}
}
The manager is a local variable in live(), retained through the Feature’s dependency. It is not an AppModel property. AppModel exposes itemsFeature; it does not expose a ModelContainer, ModelContext or startupError.
Creating this graph does not open the database. SwiftDataManager performs that work when a storage operation needs it. As other Features are added, share the same persistent-store owner where they use the same app database.
The singleton is an explicit CFA convention for the production graph, not a thread-safety mechanism. The actor boundaries do the isolation work. Tests use separately constructed Features and dependencies rather than mutating the live singleton.
Application launch is an opportunity to request a refresh, not a transaction whose success determines whether the app is allowed to display its interface:
Swift · AppDelegate.swift
import UIKit
@MainActor
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
Task {
await AppModel.shared.applicationDidFinishLaunching()
}
return true
}
}
The actual delegate callback is the lifecycle signal. Its Task enters asynchronous work; it does not make the callback wait for a database to open. Returning true here is not a claim that data has loaded successfully. Apple documents this callback as part of application launch handling.
The Feature still calls the same refresh() operation used by retry. It has no launchError and no special “startup database” mode. AppModel does not interpret or aggregate its result.
Do not replace the delegate callback with .task { await AppModel.shared.applicationDidFinishLaunching() } on a View. That ties the application’s launch notification to a screen’s appearance and task lifetime. A screen-specific task can be appropriate for genuinely screen-specific work; it is not the application launch event.
10 · CFA IN PRACTICE
No, this does not need a protocol for every Feature
It is easy for an AI refactoring to exchange one problem for another. The entry point becomes smaller, but the project gains an ItemsFeatureAPI, ItemsUseCase, ItemsCoordinator, ItemsFacade and several types that only forward identical calls.
That is not the result we want.
In current CFA, ViewModels depend directly on concrete Features. We do not add a matching ItemsFeatureAPI protocol. To test the ViewModel, construct a real ItemsFeature with a test storage dependency.
The ItemsStore protocol in this article has a concrete purpose: it allows tests to return a known collection or throw a chosen failure without touching the user’s database. That is a useful substitution boundary below the Feature, not a ceremonial second API around it.
The domain snapshot also has a reason to exist: a SwiftData-managed record and an immutable value crossing an actor boundary have different responsibilities. Conversely, inventing three equivalent snapshot structs would add conversion work without adding meaning.
Ask of every new type: “What does this own?” If the answer is only “it passes the same call to another object”, reconsider it. Keep CFA’s required screen ViewModel boundary, but do not use architecture as an excuse for unlimited indirection.
11 · CFA IN PRACTICE
Make the folder structure explain the design
A developer should be able to find the relevant layer in Xcode before opening a file. Here is the focused Items route within a fuller CFA project:
MyApp/
1 - View/
App.swift
AppDelegate.swift
Views/
ContentView.swift
Items/
ItemsView.swift
ItemsViewModel.swift
Settings/
SettingsView.swift
SettingsViewModel.swift
2 - AppModel/
AppModel.swift
Features/
Items/
ItemsFeature.swift
ItemSummary.swift
Settings/
SettingsFeature.swift
User Data Storage/
Protocols/ItemsStore.swift
Local/
SwiftDataManager.swift
ItemsRepository.swift
Existing SwiftData model files
MyAppTests/
FeatureTests/ItemsFeatureTests.swift
FeatureTests/SettingsFeatureTests.swift
ViewModelTests/ItemsViewModelTests.swift
ViewModelTests/SettingsViewModelTests.swift
PersistenceTests/ItemsRepositoryTests.swift
Support/ControlledItemsStore.swift
The article groups a few closely related declarations to explain them together. In the project, use the individual files shown above. Preserve the existing model definitions and migration history when moving persistence files.
This structure is useful to both people and AI. “Change the item-fetch retry experience” now points to a small, identifiable route. The folder name does not enforce correctness, but it makes misplaced code easier to notice.
12 · CFA IN PRACTICE
The tests should prove the reason for the refactoring
Do not stop at “the app builds”. The architectural benefit is that we can now exercise the important behaviour without constructing the entire user interface.
ItemsFeatureTests should verify that a successful request publishes its items; a failed request retains an error without claiming success; an initial failure does not become a successful empty list; a failed refresh preserves previously loaded items; and a later successful retry clears the failure. Test the declared duplicate-request and cancellation policies too.
ItemsViewModelTests should verify the user-facing message, empty-state decision, loading presentation and forwarding of retry to the concrete Feature. These tests use a real ItemsFeature with controlled storage, not an invented Feature protocol.
Persistence tests answer different questions. Can the real repository retrieve saved records? Does reopening preserve them? Can opening failures propagate without deleting data or falling back to a temporary store? Does the manager reuse the intended store owner? A fake repository cannot prove those things.
Keep a dedicated test file for each screen ViewModel and each Feature. For asynchronous overlap tests, use controlled dependencies with bounded waits rather than arbitrary sleeps. Test the awkward sequence intentionally.
Then build the actual iOS target and run its tests. Syntax parsing, a diagram and a clean-looking diff are not substitutes. An architecture inspector can help find structural problems; it cannot certify runtime correctness.
One final manual check matters here: make a data request fail and confirm that the relevant screen shows an honest error while unrelated navigation remains available. That is the product behaviour this refactoring is intended to improve.
13 · CFA IN PRACTICE
One short prompt. The toolkit carries the detail.
Once the CFA AI Toolkit for iOS is installed and available to your coding agent, the request can be this simple:
Using CFA for iOS, restructure the following code:
Paste your code underneath it.
That is the point of the toolkit. You should not have to write a page of architectural instructions every time you want to improve some code. The ownership rules, naming conventions, persistence boundaries and testing guidance already belong in its skills and specification.
You describe the work. CFA supplies the architectural guidance. In this example, the work is restructuring a tangled app entry point. In your own app, it might be improving a feature that has become difficult to maintain. The reusable part is the same: “Using CFA for iOS,”.
👉 Keep Xcode open, try the prompt on a small piece of your app, and explore the result. The walkthrough above helps you understand what changed and why; it is not a checklist you need to paste into your next prompt.
14 · CFA IN PRACTICE
Improve the toolkit, not the length of your prompt
If an agent repeatedly puts database code in a View or misses a screen’s ViewModel, the useful response is to improve the shared guidance. Fix the example, clarify the rule in the toolkit and strengthen the checks that can detect the mistake. Those improvements can then support future work without requiring every developer to rediscover the same instruction.
This is why CFA is more than an architecture diagram. Its skills bring the architecture into the coding workflow, and its inspector helps identify structural problems. The architectural detail has a maintained home instead of living in a long prompt copied between conversations.
There is still engineering judgment involved. Review the changes and run the app’s tests, just as you would with any code contribution. A short prompt makes the request simpler; it does not make AI infallible. The toolkit’s job is to help the agent do more of the work consistently, not to hand the developer a longer list of instructions to type.
15 · CFA IN PRACTICE
Better AI code starts with decisions you can reuse
Spaghetti code rarely begins with someone deciding to make a mess. It often begins with a useful fix placed wherever the problem first appeared. Then another fix joins it. Soon the app entry point knows how to open a database, interpret a failure, select a screen and recover from a storage problem.
AI can repeat that pattern very quickly. It can also help reverse it when a short request points to architectural guidance already available in its toolkit.
CFA supplies decisions about ownership, naming, screen boundaries, dependency construction and testing. The toolkit makes those decisions available to the coding workflow. The developer supplies the product requirements and checks whether the result actually meets them.
The result in this example is not fewer types. It is fewer responsibilities competing inside each type, a stable application shell, an error confined to the operation that failed, and a route that can be tested and changed deliberately.
You do not have to memorise every architectural rule or repeat it in each conversation. The toolkit carries that knowledge; understanding the result helps you keep developing it with confidence.
“Using CFA for iOS, restructure the following code:” — a small request, backed by a reusable set of architectural decisions. That is the value of the toolkit.
Get the CFA AI Toolkit for iOS, choose one tangled feature route and try the prompt.
Example scope: these are focused Swift 6 excerpts for an iOS 18+ app using Observation and SwiftData, with explicit MainActor annotations on UI-facing reference types and nonisolated default actor isolation. Existing User and Item models, unrelated Features and the production theme are intentionally not redefined. Adapt and build the complete project before adopting the code; this article is not a tested drop-in application or a migration script.
