App Architecture the Apple Way

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

This page is about where code goes in an Apple-platforms app: which types own the data, how views find that data, how services are replaced in tests and previews, and how names are chosen so the code reads well where it is called. It targets the section’s baseline: iOS, iPadOS, macOS, watchOS and visionOS 27, built with Xcode 27 and Swift 6.4, with a deployment minimum of 26 for every FieldNotes target. Nothing here needs an API newer than 26 except where a paragraph says so. The running example is the same FieldNotes app used across the section. Getting Started with Apple Platforms introduces it, and Swift Packages and Modularizing an App shows its package layout. This page shows how its pieces fit together.

The language features used here (the @Observable macro, property wrappers, protocols, structures and classes) are explained in the Swift reference and are not repeated. See Properties (section The Observation Module’s @Observable), Protocols and Structures and Classes. For general architecture theory (layers, domain modelling, repositories, CQRS), see Architectural Patterns; this page only covers what is specific to SwiftUI apps.

The Shape Apple’s Documentation Recommends

Apple’s article Managing model data in your app and the WWDC sessions Data Essentials in SwiftUI (2020) and Discover Observation in SwiftUI (2023) describe one consistent shape, often called "MV" (model and view):

  • Model data lives in observable types. A class marked @Observable holds the app’s data and the operations that change it. SwiftUI tracks which properties each view’s body reads and redraws only those views when those properties change.

  • Views are a function of that data. A view reads model properties directly. Nothing has to forward them through a separate "presentation" object.

  • Ownership is explicit. The view (or App) that creates a model holds it in @State, so SwiftUI keeps the same instance alive for the lifetime of that view.

  • Sharing goes through the environment. A model that many views need is placed in the environment once, with .environment(model), and read wherever it is needed with @Environment(Model.self). Nothing has to pass it through every initializer.

  • Services are dependencies, not globals. Networking, persistence and on-device intelligence are reached through protocols. The real implementation is injected at the top, and a fake one is injected in tests and previews.

FieldNotes follows this shape. One @Observable store, NoteStore, owns the notes and notebooks. The App owns the store. Every screen reads it from the environment, and the store talks to the outside world only through the NoteSyncing and SummaryGenerating protocols.

Figure: the FieldNotes object graph

classDiagram direction LR class FieldNotesApp { <<App>> @State store : NoteStore body : some Scene } class NoteStore { <<@Observable @MainActor>> notes : [Note] notebooks : [Notebook] add(Note) delete(Note) togglePin(Note) refresh() async throws } class EnvironmentValues { <<SwiftUI>> NoteStore (by type) summaryGenerator : any SummaryGenerating } class ContentView { <<View>> NavigationSplitView } class NoteList { <<View>> @Environment(NoteStore.self) @State searchText } class NoteEditor { <<View>> @Bindable store } class NoteSyncing { <<protocol, Sendable>> sync() async throws } class SummaryGenerating { <<protocol>> summary(for: Note) async throws String } FieldNotesApp *-- NoteStore : owns via @State FieldNotesApp ..> EnvironmentValues : .environment(store) EnvironmentValues ..> ContentView : propagates ContentView --> NoteList ContentView --> NoteEditor NoteList ..> NoteStore : reads NoteEditor ..> NoteStore : binds NoteStore --> NoteSyncing : injected in init EnvironmentValues --> SummaryGenerating : @Entry

The arrows show ownership and data flow, not inheritance. Only FieldNotesApp owns the store. Every other view observes it, and services sit behind protocols at the edge of the graph.

The Model Layer: Plain Value Types

The domain types live in the FieldNotesModel module of the local FieldNotesKit package. They are structures: a note is a value, and two notes with the same fields are the same note. They know nothing about SwiftUI. On Persisting Models with SwiftData they become persistent models; here they are plain Swift values.

// Packages/FieldNotesKit/Sources/FieldNotesModel/Note.swift
import Foundation

public struct Note: Identifiable, Hashable, Sendable {
    public var id: UUID
    public var title: String
    public var body: String
    public var createdAt: Date
    public var notebookID: Notebook.ID?
    public var tags: [String]
    public var isPinned: Bool

    public init(id: UUID = UUID(), title: String, body: String = "", createdAt: Date = .now,
                notebookID: Notebook.ID? = nil, tags: [String] = [], isPinned: Bool = false) {
        self.id = id; self.title = title; self.body = body; self.createdAt = createdAt
        self.notebookID = notebookID; self.tags = tags; self.isPinned = isPinned
    }
}

public struct Notebook: Identifiable, Hashable, Sendable {
    public var id: UUID
    public var name: String
    public var symbolName: String

    public init(id: UUID = UUID(), name: String, symbolName: String = "book.closed") {
        self.id = id; self.name = name; self.symbolName = symbolName
    }
}

Because these types are public and cross a module boundary, Sendable is declared explicitly: the compiler infers it only for non-public types. The explicit conformance lets notes cross between the main actor and background work freely, as shown on Swift Concurrency in Apps.

Observable Models: NoteStore

NoteStore is the one reference type at the heart of the app. It is @Observable, so SwiftUI tracks reads of notes and notebooks. It is @MainActor because views read it and it is mutated in response to UI events. Apps created with Xcode 26 or later already default to main-actor isolation in the app target, but this type lives in a package, which does not get that Xcode setting, so the attribute is written out.

// Packages/FieldNotesKit/Sources/FieldNotesServices/NoteStore.swift
import Observation
import FieldNotesModel

@Observable
@MainActor
public final class NoteStore {
    public var notes: [Note]
    public var notebooks: [Notebook]

    @ObservationIgnored private let syncer: any NoteSyncing

    public init(notes: [Note] = [], notebooks: [Notebook] = [],
                syncer: any NoteSyncing = NoOpNoteSyncer()) {
        self.notes = notes
        self.notebooks = notebooks
        self.syncer = syncer
    }

    public func add(_ note: Note) {
        notes.append(note)
    }

    public func delete(_ note: Note) {
        notes.removeAll { $0.id == note.id }
    }

    public func togglePin(_ note: Note) {
        guard let index = notes.firstIndex(where: { $0.id == note.id }) else { return }
        notes[index].isPinned.toggle()
    }

    public func refresh() async throws {
        try await syncer.sync()
    }
}

A few details matter:

  • @ObservationIgnored excludes the service reference from tracking. It never changes, and no view should redraw because of it.

  • The mutating operations (add, delete, togglePin) live on the store, not in views. A view says what happened ("the user pinned this note") and the store decides how the data changes. This is what keeps views small and makes the rules testable without a UI.

  • notes[index].isPinned.toggle() mutates a value inside an array held by a reference type. Observation sees one change to notes, and every view that read notes updates.

Apps written before iOS 17 used ObservableObject, @Published and @StateObject. Apple’s migration article maps each piece one-to-one. The short version is that @Observable replaces the protocol and @Published, @State replaces @StateObject, and @Environment(Type.self) replaces @EnvironmentObject:

// Before: Combine-era observable object (still compiles, but not how FieldNotes is written).
final class LegacyNoteStore: ObservableObject {
    @Published var notes: [Note] = []
}

// After: the Observation macro; only properties a view actually reads trigger updates.
@Observable
final class MigratedNoteStore {
    var notes: [Note] = []
}

Owning a Model with @State

Whoever creates an observable model owns it, and owning means holding it in @State. For the app-wide store that owner is the App itself:

// FieldNotes/FieldNotesApp.swift
import SwiftUI
import FieldNotesServices

@main
struct FieldNotesApp: App {
    @State private var store = NoteStore(syncer: CloudNoteSyncer())

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
        }
    }
}

@State tells SwiftUI to create the storage once and keep it across re-evaluations of the owning view. In Xcode 27, @State is implemented as a macro (State()), so the initial value expression runs only once. In earlier releases a class stored in @State could be constructed again every time the parent view rebuilt the child, and SwiftUI then discarded the new copy. Code that relied on side effects in a model’s initializer behaved differently because of that. With the 27 SDK this pitfall disappears; with a deployment minimum of 26, the behaviour depends on the toolchain the app is built with, not the OS it runs on. Keep model initializers free of side effects either way. State and Data Flow with Observation covers the macro in detail.

View-Local State Is Still @State

Not every piece of state belongs in the store. The search text, whether a sheet is open, and which row is selected describe this view on this screen, not the user’s data. They stay in the view as private @State:

// FieldNotes/Views/NoteList.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices

struct NoteList: View {
    @Environment(NoteStore.self) private var store
    @State private var searchText = ""
    @State private var isComposing = false

    let notebookID: Notebook.ID?

    private var visibleNotes: [Note] {
        store.notes.filter { note in
            note.notebookID == notebookID
                && (searchText.isEmpty || note.title.localizedStandardContains(searchText))
        }
    }

    var body: some View {
        List(visibleNotes) { note in
            NoteRow(note: note)
                .swipeActions {
                    Button(note.isPinned ? "Unpin" : "Pin") { store.togglePin(note) }
                }
        }
        .searchable(text: $searchText)
        .toolbar {
            Button("New Note", systemImage: "square.and.pencil") { isComposing = true }
        }
        .sheet(isPresented: $isComposing) {
            NoteComposer(notebookID: notebookID)
        }
    }
}

A useful rule is to ask whether the value would survive the view disappearing. If a note is pinned, it should stay pinned after the list scrolls away, so that belongs in the model. If the search field is cleared when the user leaves the screen, that is fine, so it stays in view state. To keep view state across launches, use @SceneStorage (see App Structure and Life Cycle (SwiftUI, UIKit, AppKit)).

Sharing Through the Environment: .environment(_:) and @Environment(Type.self)

.environment(store) in FieldNotesApp places the store in the environment of every view below it. Any view reads it by type, without an initializer parameter:

// FieldNotes/Views/NotebookList.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices

struct NotebookList: View {
    @Environment(NoteStore.self) private var store
    @Binding var selection: Notebook.ID?

    var body: some View {
        List(store.notebooks, selection: $selection) { notebook in
            Label(notebook.name, systemImage: notebook.symbolName)
                .badge(store.notes.count { $0.notebookID == notebook.id })
        }
        .navigationTitle("Notebooks")
    }
}

Two properties of this mechanism shape the architecture:

  • Reading a missing object crashes. @Environment(NoteStore.self) is non-optional, so a view used without an ancestor that called .environment(store) traps at runtime. Previews and tests must inject the store too, as shown below. Declare the property as @Environment(NoteStore.self) private var store: NoteStore? only when the object is truly optional.

  • Observation still applies. NotebookList reads store.notebooks and store.notes, so it redraws when either changes, and not when some unrelated property of the store changes.

The environment is for shared dependencies. A value that one parent hands to one child, such as the Note a row displays, is still an ordinary initializer parameter. Parameters make a view’s inputs explicit, and the environment is for everything that is ambient.

Editing Through Bindings: @Bindable

Controls such as TextField and Toggle need a Binding. An @Observable object read from the environment or passed in as a plain property doesn’t produce bindings on its own. @Bindable adds that projection ($). FieldNotes uses it in two places.

The first is inside a body, for an object that came from the environment:

// FieldNotes/Views/NoteEditor.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices

struct NoteEditor: View {
    @Environment(NoteStore.self) private var store
    let noteID: Note.ID

    var body: some View {
        @Bindable var store = store
        if let index = store.notes.firstIndex(where: { $0.id == noteID }) {
            Form {
                TextField("Title", text: $store.notes[index].title)
                TextEditor(text: $store.notes[index].body)
                Toggle("Pinned", isOn: $store.notes[index].isPinned)
            }
        } else {
            ContentUnavailableView("No Note Selected", systemImage: "note.text")
        }
    }
}

The second is as a stored property, for a small observable draft object that a sheet owns with @State and hands to a form. The draft keeps unsaved edits out of the store until the user taps Save:

// FieldNotes/Views/NoteComposer.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices

@Observable
final class NoteDraft {
    var title = ""
    var body = ""
    var tags: [String] = []
}

struct NoteComposer: View {
    @Environment(NoteStore.self) private var store
    @Environment(\.dismiss) private var dismiss
    @State private var draft = NoteDraft()
    let notebookID: Notebook.ID?

    var body: some View {
        NavigationStack {
            NoteDraftForm(draft: draft)
                .toolbar {
                    Button("Save") {
                        store.add(Note(title: draft.title, body: draft.body,
                                       notebookID: notebookID, tags: draft.tags))
                        dismiss()
                    }
                    .disabled(draft.title.isEmpty)
                }
        }
    }
}

struct NoteDraftForm: View {
    @Bindable var draft: NoteDraft

    var body: some View {
        Form {
            TextField("Title", text: $draft.title)
            TextField("Body", text: $draft.body, axis: .vertical)
        }
    }
}

NoteDraftForm doesn’t own the draft, so it uses @Bindable rather than @State. If it declared the draft as @State, it would own a copy of the reference, and a parent that replaced the draft would not be seen.

Custom Environment Values with @Entry

Services that are not observable models, such as the summary generator, are injected as environment values. Declaring a custom value used to take an EnvironmentKey type plus a computed property. The @Entry macro generates both from one line:

// FieldNotes/Environment/EnvironmentValues+Services.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices

extension EnvironmentValues {
    @Entry var summaryGenerator: any SummaryGenerating = UnavailableSummaryGenerator()
}

// FieldNotesServices: the fallback used when nothing else is injected.
public struct UnavailableSummaryGenerator: SummaryGenerating {
    public init() {}
    public func summary(for note: Note) async throws -> String {
        throw SummaryError.unavailable
    }
}

public enum SummaryError: Error { case unavailable }

The app injects the real implementation once, next to the store. A view reads it by key path. Here the detail screen offers a Summarize button:

// FieldNotesApp.body -- the real generator is implemented with Foundation Models on page 29.
WindowGroup {
    ContentView()
        .environment(store)
        .environment(\.summaryGenerator, FoundationModelSummaryGenerator())
}

// FieldNotes/Views/NoteDetail.swift
struct NoteDetail: View {
    @Environment(\.summaryGenerator) private var summaryGenerator
    @State private var summary: String?
    let note: Note

    var body: some View {
        ScrollView {
            Text(note.body)
            if let summary { Text(summary).font(.callout).foregroundStyle(.secondary) }
        }
        .toolbar {
            Button("Summarize", systemImage: "sparkles") {
                Task { summary = try? await summaryGenerator.summary(for: note) }
            }
        }
    }
}

FoundationModelSummaryGenerator is written on Apple Intelligence and Machine Learning. Because the view depends only on the protocol, this page, previews and tests never need the model to be available.

Protocol-Typed Services and Test Doubles

FieldNotes injects its two services in two different ways, and both are legitimate:

Service Injection Why

NoteSyncing

Through NoteStore's initializer

The store uses it, not views; the store’s tests construct it directly.

SummaryGenerating

As an @Entry environment value

Views use it directly; previews swap it with one modifier.

The protocols are the ones the whole section uses:

// Packages/FieldNotesKit/Sources/FieldNotesServices/Services.swift
import FieldNotesModel

public protocol NoteSyncing: Sendable {
    func sync() async throws
}

public protocol SummaryGenerating {
    func summary(for note: Note) async throws -> String
}

public struct NoOpNoteSyncer: NoteSyncing {
    public init() {}
    public func sync() async throws {}
}

NoteSyncing refines Sendable because the syncer is stored in a main-actor store but does its work elsewhere, typically in an actor. SummaryGenerating is called only from main-actor views and does not need to be.

A test double is just another conforming type. An actor is a convenient one because it records calls safely. The test below uses Swift Testing, which Unit Testing with Swift Testing covers in full:

// FieldNotesTests/NoteStoreTests.swift
import Testing
import FieldNotesModel
import FieldNotesServices

actor RecordingNoteSyncer: NoteSyncing {
    private(set) var syncCount = 0
    func sync() async throws { syncCount += 1 }
}

@MainActor
struct NoteStoreTests {
    @Test func refreshSyncsExactlyOnce() async throws {
        let syncer = RecordingNoteSyncer()
        let store = NoteStore(syncer: syncer)

        try await store.refresh()

        let count = await syncer.syncCount
        #expect(count == 1)
    }

    @Test func togglePinFlipsOnlyThatNote() {
        let pinned = Note(title: "Heron", isPinned: true)
        let other = Note(title: "Kingfisher")
        let store = NoteStore(notes: [pinned, other])

        store.togglePin(pinned)

        #expect(store.notes.map(\.isPinned) == [false, false])
    }
}

Previews use the same seam. A preview gets a store full of sample data and a summary generator that answers instantly, so the canvas never touches the network or the on-device model:

// FieldNotes/Preview Content/PreviewSupport.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices

struct CannedSummaryGenerator: SummaryGenerating {
    func summary(for note: Note) async throws -> String { "A short summary of \(note.title)." }
}

extension NoteStore {
    static var preview: NoteStore {
        NoteStore(notes: [
            Note(title: "Heron at the lake", tags: ["birds"], isPinned: true),
            Note(title: "Moss on the north wall", tags: ["plants"]),
        ])
    }
}

#Preview {
    NavigationStack {
        NoteDetail(note: NoteStore.preview.notes[0])
    }
    .environment(NoteStore.preview)
    .environment(\.summaryGenerator, CannedSummaryGenerator())
}

MV with Observation vs MVVM: a Fair Comparison

Many teams arrive at SwiftUI from UIKit or Android with Model-View-ViewModel habits, and the debate is often framed as "SwiftUI doesn’t need view models". The accurate statement is narrower. With Observation, a view model is just another @Observable type, and SwiftUI treats it exactly like NoteStore. The framework has no special view-model concept to adopt or reject. The real question is whether a screen has enough logic of its own to deserve a separate, testable type.

Figure: MV with Observation vs MVVM

flowchart LR subgraph MV["MV with Observation"] direction TB S1["NoteStore
@Observable, app-wide"] -->|"@Environment(NoteStore.self)"| V1["NoteList
@State searchText
filters in a computed property"] V1 -->|"store.togglePin(note)"| S1 end subgraph MVVM["MVVM with Observation"] direction TB S2["NoteStore
@Observable, app-wide"] -->|"notes passed in"| VM["NoteListViewModel
@Observable, per screen
searchText, showsPinnedOnly"] VM -->|"visibleNotes"| V2["NoteList
@State viewModel"] V2 -->|"$viewModel.searchText"| VM V2 -->|"store.togglePin(note)"| S2 end

The same NoteList written the MVVM way moves filtering state and logic into a per-screen observable type:

// FieldNotes/Views/NoteListViewModel.swift
import Observation
import FieldNotesModel

@Observable
final class NoteListViewModel {
    var searchText = ""
    var showsPinnedOnly = false

    func visibleNotes(from notes: [Note], in notebookID: Notebook.ID?) -> [Note] {
        notes.filter { note in
            note.notebookID == notebookID
                && (!showsPinnedOnly || note.isPinned)
                && (searchText.isEmpty || note.title.localizedStandardContains(searchText))
        }
    }
}

struct NoteListMVVM: View {
    @Environment(NoteStore.self) private var store
    @State private var viewModel = NoteListViewModel()
    let notebookID: Notebook.ID?

    var body: some View {
        List(viewModel.visibleNotes(from: store.notes, in: notebookID)) { note in
            NoteRow(note: note)
        }
        .searchable(text: $viewModel.searchText)
        .toolbar { Toggle("Pinned Only", isOn: $viewModel.showsPinnedOnly) }
    }
}

Compared honestly:

Concern MV with Observation MVVM with Observation

Where screen logic lives

Computed properties and small helpers on the view, or on the model

A per-screen @Observable class

Testing screen logic

Test the model; view helpers are tested through previews or UI tests

Unit-test the view model directly, with no SwiftUI involved

Amount of code

Least; nothing to forward

One more type per screen; risk of "pass-through" properties

Update granularity

Identical — Observation tracks property reads in both cases

Identical

Good fit

Screens that mostly display and edit shared data

Screens with real state machines: multi-step flows, validation, paging

FieldNotes uses MV by default and introduces a view model only where a screen has logic worth testing on its own. The anti-pattern to avoid is a view model that merely re-exposes store properties one by one. It doubles the code and adds a place for the two copies to disagree. For the broader pattern vocabulary, see Architectural Patterns.

Value Types vs Reference Types

Swift’s split between structures and classes (explained in Structures and Classes) maps directly onto app architecture:

  • Values for data. Note and Notebook are structs. Copying one produces an independent value, so a note passed to NoteRow can’t be mutated behind the row’s back, and values cross concurrency domains safely.

  • References for identity and shared mutable state. NoteStore is a class because there must be exactly one store that every view observes. Two copies would be two separate sources of truth.

  • Views are values too. A SwiftUI View is a lightweight struct describing UI. SwiftUI owns the real, long-lived state (@State), which is why a view never needs to be a class.

The consequence shows up in how data is edited. A view that receives a Note value cannot "save" it by mutating its copy. It asks the store to change the canonical value:

// FieldNotes/Views/NoteRow.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices

struct NoteRow: View {
    @Environment(NoteStore.self) private var store
    let note: Note                      // a value: this row's snapshot

    var body: some View {
        HStack {
            Text(note.title)
            Spacer()
            Button(note.isPinned ? "Unpin" : "Pin",
                   systemImage: note.isPinned ? "pin.fill" : "pin") {
                store.togglePin(note)   // the store mutates the one canonical copy
            }
            .labelStyle(.iconOnly)
        }
    }
}

When a type needs identity and is part of the data, as a SwiftData @Model does, it becomes a class. That is a persistence decision covered on Persisting Models with SwiftData, not a reason to make every model a class.

Feature Modularization

The architecture above maps onto modules. Types go in FieldNotesModel, the store and service protocols go in FieldNotesServices, shared views go in FieldNotesUI, and the app target only assembles them and injects the real services. This keeps build times down, makes access control meaningful, and lets the watchOS app and the widgets reuse the same model and services:

// swift-tools-version: 6.4
// Packages/FieldNotesKit/Package.swift
import PackageDescription

let package = Package(
    name: "FieldNotesKit",
    platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26), .visionOS(.v26)],
    products: [
        .library(name: "FieldNotesModel", targets: ["FieldNotesModel"]),
        .library(name: "FieldNotesServices", targets: ["FieldNotesServices"]),
    ],
    targets: [
        .target(name: "FieldNotesModel"),
        .target(name: "FieldNotesServices", dependencies: ["FieldNotesModel"]),
    ]
)

The dependency direction is the architectural rule. FieldNotesModel imports nothing from the app, FieldNotesServices imports only the model, and only the app target knows which concrete services exist. Swift Packages and Modularizing an App explains the package mechanics, and One Codebase for iOS, iPadOS, macOS, watchOS and visionOS shows how the same modules serve every platform target.

The Swift API Design Guidelines in App Code

Apple’s frameworks, and the Swift standard library, follow the Swift API Design Guidelines. App code that follows them reads like the frameworks it calls. The language-level summary is on Lexical Structure and Style; this section shows what the guidelines change in FieldNotes' own API.

Clarity at the point of use

Names are judged where they are called, not where they are declared. Omit words that repeat type information, and keep the words a reader needs:

// Before: redundant words, unclear argument.
store.deleteNote(note: note)
store.notes("birds")

// After: reads as a phrase at the call site.
store.delete(note)
store.notes(taggedWith: "birds")

extension NoteStore {
    public func notes(taggedWith tag: String) -> [Note] {
        notes.filter { $0.tags.contains(tag) }
    }
}

Naming by role, not by type

A parameter or property is named for what it means, not for its type:

// Before
func move(_ note: Note, _ notebook: Notebook)
var string: String          // on NoteDraft

// After
func move(_ note: Note, to destination: Notebook)
var title: String

Naming for side effects: sort / sorted

Mutating methods read as imperative verbs, and their non-mutating counterparts read as nouns or use the "-ed" / "-ing" suffix, exactly like the standard library’s sort() and sorted():

extension Note {
    /// Adds `tag` to this note in place.
    public mutating func addTag(_ tag: String) {
        if !tags.contains(tag) { tags.append(tag) }
    }

    /// Returns a copy of this note with `tag` added.
    public func addingTag(_ tag: String) -> Note {
        var copy = self
        copy.addTag(tag)
        return copy
    }
}

var heron = Note(title: "Heron")
heron.addTag("birds")                            // mutates
let tagged = heron.addingTag("lake")             // returns a new value
let newestFirst = store.notes.sorted { $0.createdAt > $1.createdAt }

Argument labels

Label the first argument when it forms a prepositional or grammatical phrase, and omit it when the method name already describes the argument. Initializers that do a value-preserving conversion omit it too:

store.add(note)                                  // name already says what the argument is
store.move(note, to: archive)                    // preposition belongs to the label
store.notes(taggedWith: "birds")                 // phrase continues into the label
let notebook = Notebook(name: "Field trip 2026") // initializer labels name each field

Documentation comments

Every public declaration in FieldNotesKit carries a /// comment. Xcode shows it in Quick Help, and DocC builds it into documentation. Start with a one-sentence summary, then add - Parameter, - Returns and - Throws where they apply:

extension NoteStore {
    /// Moves a note into a different notebook.
    ///
    /// The note keeps its identifier, tags and pin state; only its notebook changes.
    ///
    /// - Parameters:
    ///   - note: The note to move. Notes the store doesn't contain are ignored.
    ///   - destination: The notebook that receives the note.
    public func move(_ note: Note, to destination: Notebook) {
        guard let index = notes.firstIndex(where: { $0.id == note.id }) else { return }
        notes[index].notebookID = destination.id
    }
}

Learning from Apple’s Sample Apps

Apple publishes complete sample projects that use the architecture on this page. They are the best way to see the pieces working together at full size:

Sample What to study in it

Backyard Birds: Building an app with SwiftData and widgets

A model-driven app built on persistent models, with the data shared between the app and interactive widgets through an App Group, and a StoreKit in-app purchase flow. It is the closest analogue to FieldNotes once parts 2 and 3 add persistence and widgets.

Food Truck: Building a SwiftUI multiplatform app

A single app target for iPhone, iPad and Mac with one observable model driving a NavigationSplitView, platform-conditional views, Charts and widgets. It shows how one model layer serves several platforms.

Landmarks: Building an app with Liquid Glass

The current version of Apple’s classic tutorial app: an @Observable model placed in the environment, collection and detail views, and the Liquid Glass design system covered on Liquid Glass, Materials and Icons.

Download a sample from its documentation page, open the .xcodeproj, and trace the path from the App struct to the first environment injection. The same few types appear in every one.