top of page

01 · CFA IN PRACTICE

SwiftUI Views and ViewModels: The Boundary That Keeps iOS Apps Understandable

Many SwiftUI projects become difficult to read for an unexpectedly small reason: a screen starts doing its own work. A View opens storage, formats a service error, starts a network request or decides which business rule applies. It can still look elegant in a screenshot. It can even work. But the next person cannot tell whether they are looking at an interface description or an application controller.

The useful mental model is simpler than it first appears. A SwiftUI View is a declaration of an interface. A ViewModel is an imperative, screen-owned object that receives user intent and presents feature state. A Feature owns the product capability behind that screen.

CFA — the CFA AI Toolkit for iOS — makes that boundary deliberately visible. Each screen gets an adjacent ViewModel. The ViewModel receives one concrete Feature. The Feature owns the useful work. The result is not more ceremony. It is a small route that a reader can follow without guessing.

View → ViewModel → Feature → lower-level service.

This is the complete route for a screen action. It stays short on purpose.

By the end of this article, you should be able to look at a SwiftUI file and answer two questions immediately: “What does this screen describe?” and “Where does its work actually happen?”

02 · CFA IN PRACTICE

A View is a description, not a little controller

A type that conforms to SwiftUI’s View protocol supplies a body that describes the hierarchy SwiftUI should show. SwiftUI may read that computed property repeatedly when user input or system state changes. Apple describes this repeated body evaluation in its View documentation.

That is why a View must be safe to construct and discard. It is a value describing a moment of the interface, not a durable owner of a database, a request, a business rule or an error policy. Re-evaluating body should change what the user sees, not quietly write data, open a store or start another operation.

Here is the smallest compliant route. The View owns one ViewModel. The ViewModel owns one matching Feature. The Feature is the first place where the screen can make a request.

Swift · MessagesRoute.swift

import Observation
import SwiftUI

struct MessagesView: View {
    @State private var viewModel = MessagesViewModel()

    var body: some View {
        List(viewModel.messages) { message in
            Text(message.text)
        }
        .toolbar {
            Button("Refresh") {
                viewModel.refreshTapped()
            }
        }
    }
}

@MainActor
@Observable
final class MessagesViewModel {
    private let messagesFeature: MessagesFeature

    var messages: [MessageSummary] {
        messagesFeature.messages
    }

    init(messagesFeature: MessagesFeature = AppModel.shared.messagesFeature) {
        self.messagesFeature = messagesFeature
    }

    func refreshTapped() {
        Task { [messagesFeature] in
            await messagesFeature.refresh()
        }
    }
}

@MainActor
@Observable
final class MessagesFeature {
    private(set) var messages: [MessageSummary] = []

    func refresh() async {
        // Request messages through the Feature's injected store.
    }
}

There is no store in the View, no stored AppModel, and no asynchronous work in body. The View sends an intent; the ViewModel owns the imperative response; the Feature owns the capability. The following sections give each of those small types its full implementation.

03 · CFA IN PRACTICE

View reconstruction is normal; side effects are not

It is tempting to reason about a SwiftUI View as if it were a long-lived screen object. It is not a dependable assumption. SwiftUI is free to create fresh View values while it evaluates and updates the interface. The important contract is that describing the View remains harmless.

@State is the bridge to storage that SwiftUI manages for a View identity. Apple’s documentation explains that SwiftUI manages that storage, and that observable objects can be stored there. It also warns against side effects or expensive work when creating a state default. Read the State documentation.

SwiftUI may recreate a View value
             │
             ▼
The View describes the same screen identity
             │
             ▼
@State retains the screen-owned ViewModel for that identity
             │
             ▼
The ViewModel requests work from its Feature

This is an important refinement. A ViewModel is not magically immortal: if a screen receives a new identity, its local state is new too. That is usually correct. The goal is narrower and more useful: rebuilding a View value must not itself produce a business side effect. The ViewModel initializer should therefore only capture its dependency. It should not fetch, write, open storage or begin a timer.

04 · CFA IN PRACTICE

One screen gets one ViewModel

CFA tightly couples a screen View with its own ViewModel because their responsibilities are complementary, not interchangeable. The ViewModel is the imperative window between the business capability and the visual screen. It owns presentation state: a selection, a draft, an alert, a loading transition or wording that is appropriate for this screen.

That does not mean the ViewModel is a second Feature. It does not decide domain policy simply because it can observe it. A Feature owns application behaviour and shared state. The ViewModel translates feature outcomes into screen-facing values and forwards screen intent.

  • View: layout, accessibility, visual conditionals, bindings and forwarding a user action.
  • ViewModel: screen presentation state, asynchronous UI workflow and translating an intent into a Feature request.
  • Feature: product capability, feature state and business decisions.
  • Manager or provider: an external boundary such as SwiftData, networking, location or user defaults.

“Tightly coupled” here means intentionally adjacent and named together: MessagesView has MessagesViewModel. It does not mean the ViewModel reaches everywhere in the app. Its one dependency is its matching MessagesFeature.

05 · CFA IN PRACTICE

The CFA route is narrow enough to see

The architecture route below is an ownership map, not a collection of layers to admire. Each arrow answers one question: who is allowed to call whom?

MessagesView
  renders screen state and forwards a tap
        ↓
MessagesViewModel
  owns the screen workflow and presentation state
        ↓
MessagesFeature
  owns the messages capability and its shared state
        ↓
MessagesStore / NetworkingManager / SwiftDataManager
  owns the external-system boundary

Nothing is gained by making this route longer. CFA does not ask for an API protocol, coordinator or façade for every Feature. A concrete Feature is already the named application capability. Extra forwarding types obscure the ownership the architecture is trying to make obvious.

The boundary becomes especially valuable when AI helps write the code. The instruction “Using CFA for iOS, restructure the following code:” is useful because the toolkit supplies the rules behind those four arrows. The short prompt is not asking the agent to invent an architecture from scratch.

06 · CFA IN PRACTICE

What the View looks like when it stays declarative

The View owns its adjacent ViewModel as private SwiftUI state. Its body reads screen-facing values and describes an error, loading state or list. A button reports an intent. It does not know how messages are fetched.

Swift · MessagesView.swift

import SwiftUI

struct MessagesView: View {
    @State private var viewModel = MessagesViewModel()

    var body: some View {
        List {
            if let errorMessage = viewModel.errorMessage {
                ContentUnavailableView(
                    "Messages unavailable",
                    systemImage: "exclamationmark.bubble",
                    description: Text(errorMessage)
                )

                Button("Try again") {
                    viewModel.retryTapped()
                }
            } else if viewModel.isLoading {
                ProgressView("Loading messages…")
            } else {
                ForEach(viewModel.messages) { message in
                    Text(message.text)
                }
            }
        }
        .navigationTitle("Messages")
    }
}

The View is still useful code. It chooses a title, order, button label, accessibility text and visual state. It is simply not where application work is implemented. The button reports one intent; the ViewModel owns the method, task policy and presentation consequences.

If that workflow needs cancellation, replacement or debouncing, keep the task handle and decision inside the ViewModel. Do not add a stored task to the View just because the operation began at a button tap.

07 · CFA IN PRACTICE

The ViewModel is an imperative window, not an AppModel clone

Here, MessagesViewModel has one narrow dependency. It exposes what the screen needs and turns the screen’s refresh request into a Feature operation. Its initializer is intentionally quiet: it does not begin loading merely because SwiftUI has instantiated a View value.

Swift · MessagesViewModel.swift

import Observation

@MainActor
@Observable
final class MessagesViewModel {
    private let messagesFeature: MessagesFeature
    private var refreshTask: Task<Void, Never>?

    var messages: [MessageSummary] {
        messagesFeature.messages
    }

    var isLoading: Bool {
        messagesFeature.isRefreshing
    }

    var errorMessage: String? {
        messagesFeature.refreshError.map {
            "We could not load your messages. Please try again."
        }
    }

    init(messagesFeature: MessagesFeature = AppModel.shared.messagesFeature) {
        self.messagesFeature = messagesFeature
    }

    func retryTapped() {
        refreshTask?.cancel()
        refreshTask = Task { [messagesFeature] in
            await messagesFeature.refresh()
        }
    }
}

Notice the default argument. Production code can write MessagesViewModel(). The ViewModel retrieves only AppModel.shared.messagesFeature, not a stored reference to all of AppModel. A test can construct a concrete MessagesFeature with controlled lower-level dependencies and provide it through the same parameter.

This is why CFA can be both convenient and testable without an MessagesFeatureAPI protocol. Feature protocols are a waste of time when their only purpose is to duplicate a concrete Feature so a ViewModel can call the same methods. Put a protocol below the Feature only when a genuine external boundary needs a substitute.

08 · CFA IN PRACTICE

The Feature owns the capability

The Feature is where “refresh messages” means something to the application. It owns the loaded values, the failure state and the rule that prevents two refreshes from running at once. It asks an injected store to do the external work; it does not ask a View or ViewModel to open persistence.

Swift · MessagesFeature.swift

import Observation

@MainActor
@Observable
final class MessagesFeature {
    private let messagesStore: MessagesStore

    private(set) var messages: [MessageSummary] = []
    private(set) var isRefreshing = false
    private(set) var refreshError: Error?

    init(messagesStore: some MessagesStore) {
        self.messagesStore = messagesStore
    }

    func refresh() async {
        guard !isRefreshing else { return }
        isRefreshing = true
        refreshError = nil

        do {
            let refreshedMessages = try await messagesStore.fetchMessages()
            guard !Task.isCancelled else { return }
            messages = refreshedMessages
        } catch {
            guard !Task.isCancelled else { return }
            refreshError = error
        }

        isRefreshing = false
    }
}

The ViewModel may choose the friendly sentence shown for refreshError. The Feature decides whether the request is already running and preserves the messages it previously loaded. That split prevents screen wording from quietly becoming business policy and prevents persistence details from leaking upward.

For example, if the store is SwiftData-backed, a SwiftDataManager owns opening and accessing the container. If the store performs network calls, a NetworkingManager wraps URLSession. Views and ViewModels do not construct either boundary.

09 · CFA IN PRACTICE

AppModel composes; it does not participate in every screen

AppModel is CFA’s live composition root. Its job is to create the live graph once and expose Features. It is not a ViewModel, a service locator for arbitrary values or a place to keep low-level objects such as ModelContainer.

Swift · AppModel.swift

import Observation

@MainActor
@Observable
final class AppModel {
    static let shared = AppModel.live()

    let messagesFeature: MessagesFeature
    private var didFinishLaunching = false

    init(messagesFeature: MessagesFeature) {
        self.messagesFeature = messagesFeature
    }

    static func live() -> AppModel {
        let networkingManager = NetworkingManager()
        let messagesStore = LiveMessagesStore(
            networkingManager: networkingManager
        )

        return AppModel(
            messagesFeature: MessagesFeature(messagesStore: messagesStore)
        )
    }

    func applicationDidFinishLaunching() {
        guard !didFinishLaunching else { return }
        didFinishLaunching = true
    }
}

The manager is local to live() and retained by the Feature that uses it. AppModel exposes messagesFeature, not the manager or its container. That keeps a screen’s dependency direction visible: its ViewModel receives the concrete Feature it owns, and nothing else.

A CFA AppModel has only its Feature properties, its shared singleton and its launch-once guard. It does not store loading flags, database errors, screen selections or raw services. Those values all have a more truthful owner elsewhere.

10 · CFA IN PRACTICE

Where side effects belong

“No imperative logic in Views” does not mean an app never performs imperative work. It means the work has an explicit owner and an explicit entry point. The list below can be reduced to one review question: does this statement describe the interface, or does it make something happen?

  • A View may describe a progress indicator or error state. It does not open a database to discover whether one exists.
  • A ViewModel may start, cancel or sequence a screen workflow. It does not make a business rule true merely because that is convenient for UI code.
  • A Feature requests persistence or networking through its injected collaborator and owns the result relevant to its capability.
  • A Manager owns the external integration, including configuration and safe error propagation.

The same rule applies to app launch. A real lifecycle callback may tell AppModel that launch occurred, and AppModel may notify Features once. It must not turn a failed request into a “launch error” that prevents the interface from existing. If conversations cannot be retrieved, the conversations Feature reports a failed retrieval and its screen presents recovery.

11 · CFA IN PRACTICE

Test the boundary instead of testing SwiftUI value creation

This separation produces small, relevant tests. A Feature test can control its store and prove the product rule: a failed refresh preserves already-loaded messages. A ViewModel test can prove presentation behaviour: a failed Feature outcome becomes the sentence and retry state the screen needs. A View snapshot or UI test can prove the visual rendering if that is useful.

The ViewModel’s one initializer parameter is its test seam. There is no need to overwrite AppModel.shared, construct SwiftData inside a View test or reproduce the entire application graph to exercise a screen outcome.

This is also a review tool for AI-generated changes. Do not only ask whether the files compile. Ask whether every screen still follows the visible route, whether the View initializer has remained harmless, and whether the test exercises a behaviour at the owner that promises it.

12 · CFA IN PRACTICE

The mental model to carry into the next screen

A View is disposable description. A ViewModel is the durable, imperative companion for one screen identity. A Feature is the product capability behind it. This gives each file a small, honest job.

When a View is reconstructed, it should not alter the world. When a user acts, the ViewModel interprets that action for its screen and asks the Feature to do the work. When the work completes, the Feature owns the capability state and the ViewModel makes it presentable. The View simply describes the result.

Keep the View light enough to throw away. Keep the ViewModel close enough to explain the screen. Keep the Feature substantial enough to own the work.

That is the CFA decision: fewer magical paths, fewer indirections and a clean route from a visible screen to the one capability it represents.

13 · CFA IN PRACTICE

Try the prompt on one screen you already own

Install the CFA AI Toolkit for iOS, then choose one screen that currently mixes rendering with data work. Start with this small prompt:

Using CFA for iOS, restructure the following code:

Paste the View and the smallest related dependency route underneath.

The useful result is not a larger set of abstractions. It is a screen whose View describes UI, whose ViewModel owns the screen workflow and whose Feature owns the capability. Read the generated change, build it, and test the behaviour that motivated the refactor.

Example scope: This article teaches a CFA ownership model for modern SwiftUI applications. The Swift snippets are focused excerpts, not a copy-and-paste application. They assume your project supplies its domain models and storage implementation. Verify compilation, concurrency isolation, error handling, accessibility and data-migration behaviour in the complete app before adopting a refactor.

bottom of page