Sample CodeiOS, iPadOS, Mac Catalyst, watchOSReviewed 2026-11-01View on Apple Developer

Updating an app to use Swift concurrency

At a glance

Item Summary
Purpose Refactor the Coffee Tracker watchOS app from completion handlers and dispatch queues to async/await, actors, and a main-actor model.
App architecture Two parallel copies of one app, Starting Point and Updated. Each has a SwiftUI watch extension whose views, complication data source, and background-refresh delegate all reach one shared CoffeeData model. That model delegates disk I/O to CoffeeDataStore and HealthKit I/O to HealthKitController.
Main patterns Actor isolation, Publisher-backed observable state, SwiftUI environment injection, Delegate or data-source callbacks, Singleton, Callback-to-async continuation bridge
Project style Flat, file-per-type WatchKit extension folder; no View/Model subfolders. Each variant ships an iOS container app, a WatchKit app, and a WatchKit extension target.
Execution model Updated: @MainActor on CoffeeData, two actor types, four unretained plain Task {} creations, and one checked continuation. Starting Point: a private serial DispatchQueue plus DispatchQueue.main.async hand-backs. Neither variant uses Task.detached, task groups, or async sequences.
State/event model ObservableObject with one @Published array, injected through .environmentObject; WatchKit and ClockKit call back into a delegate and a data source.
Key frameworks/packages SwiftUI, WatchKit, ClockKit, HealthKit, os, and Foundation imports. No Swift package manifest.

Platform note: the catalog lists iOS, iPadOS, Mac Catalyst, and watchOS. In the project file, the shipping code lives in watchOS targets (SDKROOT = watchos, WATCHOS_DEPLOYMENT_TARGET = 8.0). The project-level SDKROOT = iphoneos setting applies to the watchapp2-container target, which has no Swift sources (Updated/Coffee Tracker.xcodeproj/project.pbxproj:215, Updated/Coffee Tracker.xcodeproj/project.pbxproj:500).

Project structure

Updating an app to use Swift concurrency/
├── Configuration/SampleCode.xcconfig
├── Starting Point/                       completion handlers + DispatchQueue
│   ├── Coffee Tracker.xcodeproj
│   ├── Coffee Tracker WatchKit App/      Info.plist and assets only
│   └── Coffee Tracker WatchKit Extension/
│       └── (same 11 Swift files as Updated)
└── Updated/                              async/await + actors
    ├── Coffee Tracker.xcodeproj          3 targets: container, watch app, extension
    ├── Coffee Tracker WatchKit App/      Info.plist and assets only
    └── Coffee Tracker WatchKit Extension/
        ├── CoffeeTrackerApp.swift        @main App + delegate adaptor
        ├── ExtensionDelegate.swift       background refresh handling
        ├── ContentView.swift             root view, scene-phase reactions
        ├── CoffeeTrackerView.swift       main screen
        ├── DrinkListView.swift           add-drink sheet
        ├── CoffeeData.swift              @MainActor model + private CoffeeDataStore actor
        ├── HealthKitController.swift     HealthKit actor
        ├── ComplicationController.swift  CLKComplicationDataSource
        ├── Drink.swift / DrinkType.swift value types
        ├── HostingController.swift       WKHostingController (not referenced)
        └── Info.plist                    principal-class wiring

Structure observations

  • The two variants have the same file names and type names. The sample teaches by diff: compare Starting Point/…/CoffeeData.swift with Updated/…/CoffeeData.swift to see the migration.
  • Only the extension target compiles Swift. The WatchKit App folder holds only Info.plist and assets (Updated/Coffee Tracker.xcodeproj/project.pbxproj:232, Updated/Coffee Tracker.xcodeproj/project.pbxproj:249).
  • Info.plist sets the two framework entry classes by name. CLKComplicationPrincipalClass selects ComplicationController, and WKExtensionDelegateClassName selects ExtensionDelegate (Updated/Coffee Tracker WatchKit Extension/Info.plist:23, Updated/Coffee Tracker WatchKit Extension/Info.plist:39).
  • HostingController is compiled but nothing in the source references it. The SwiftUI @main app supplies the UI instead (Updated/Coffee Tracker WatchKit Extension/HostingController.swift:11).
  • In the Updated variant, CoffeeData.swift holds two types: the private persistence actor and the implicitly internal CoffeeData model (only its currentDrinks member is public private(set)). The file boundary keeps CoffeeDataStore hidden from the rest of the module.

Overall architecture

Reference code

Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:98 — CoffeeData

@MainActor
class CoffeeData: ObservableObject {
    // ...
    static let shared = CoffeeData()
    lazy var healthKitController = HealthKitController(withModel: self)

    // An actor used to save and load the model data away from the main thread.
    private let store = CoffeeDataStore()

    // ...
    @Published public private(set) var currentDrinks: [Drink] = []
    // ...
}

Interpretation

Observed: three framework entry points reach the model through the same static CoffeeData.shared: the SwiftUI root view (Updated/Coffee Tracker WatchKit Extension/ContentView.swift:19), the complication data source (Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift:13), and the background-refresh delegate (Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:33). The model owns two actor collaborators. HealthKitController keeps a weak back-reference so it can push HealthKit changes into the model.

Inference: the singleton exists because ClockKit and WatchKit create ComplicationController and ExtensionDelegate from Info.plist class names, so SwiftUI cannot inject the model into them. The comment at Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:103 says the model “needs to be accessed both from the app extension and from the complication controller”.

Ownership and state

Ownership evidence

Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:26 — HealthKitController

actor HealthKitController {
    // ...
    // A weak link to the main model.
    private weak var model: CoffeeData?

    // Indicates whether the user has authroized access to their health data
    private lazy var isAuthorized = false

    // ...
    init(withModel model: CoffeeData) {
        self.model = model
    }
    // ...
}
Owner Object or state Relationship Mutation authority
CoffeeData (static shared) itself private init() plus static let shared makes this the only instance (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:105, Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:248) n/a
CoffeeData currentDrinks: [Drink] Stores it as @Published public private(set) (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:114) Only CoffeeData: addDrink, updateModel, and load
CoffeeData CoffeeDataStore Creates and retains it with private let (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:109) CoffeeData awaits save and load
CoffeeDataStore savedValue: [Drink] Actor-isolated private state (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:18) CoffeeDataStore.save and load only
CoffeeData HealthKitController Creates it lazily and passes self (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:106) Callers await its methods
HealthKitController model weak back-reference (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:34) Calls await model?.updateModel(...)
HealthKitController anchor, isAuthorized Actor-isolated; anchor is a computed property backed by UserDefaults (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:40) HealthKitController only
File scope in HealthKitController.swift HKHealthStore, caffeine type, units private let globals (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:16) Immutable bindings
CoffeeTrackerApp ExtensionDelegate @WKExtensionDelegateAdaptor private var (Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerApp.swift:13) SwiftUI and WatchKit
ContentView CoffeeData.shared Holds a reference and injects it with .environmentObject(data) (Updated/Coffee Tracker WatchKit Extension/ContentView.swift:19, Updated/Coffee Tracker WatchKit Extension/ContentView.swift:24) CoffeeData
CoffeeTrackerView showDrinkList @State that drives the sheet (Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerView.swift:14) The view

Observed weak point: CoffeeData owns HealthKitController strongly, and the controller points back with weak. This breaks the reference cycle the two-way relationship would otherwise create.

Concurrency, scheduling, and thread safety

Evidence limit: actor isolation, async/await, or Task creation does not by itself prove background-thread execution; Sendable conformance alone does not prove thread-safe mutation.

Build settings: SWIFT_VERSION = 5.0, and the project file contains no strict-concurrency setting (Updated/Coffee Tracker.xcodeproj/project.pbxproj:498). The compiler therefore applies Swift 5 language-mode checking. Some cross-isolation accesses below compile without visible await, and the source does not show which SDK isolation makes them legal.

Concern Source mechanism Verified placement or handoff Evidence
Main-actor isolation @MainActor class CoffeeData All CoffeeData state is main-actor isolated, including currentDrinks, the lazy healthKitController, and the computed caffeine values. Other isolation domains must await to call into it. Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:98
Persistence isolation private actor CoffeeDataStore savedValue and the encode/write and read/decode work are actor-isolated. CoffeeData crosses into the actor with await store.save(...) and await store.load(). The comment says “away from the main thread”. The source establishes a separate isolation domain, not a specific thread. Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:14, Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:126, Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:256
HealthKit isolation actor HealthKitController Protects anchor reads and writes, isAuthorized, and the weak model. The controller returns results to the main actor by awaiting model?.updateModel(...). The comment above that call still says “main queue”, left over from the dispatch version. Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:26, Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:152
Callback bridge withCheckedThrowingContinuation Wraps the HKAnchoredObjectQuery results handler. Each branch resumes exactly once, either resume(throwing:) or resume(returning:), and then store.execute(query) starts the query. Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:98
Task creation (model) Plain Task {} in addDrink Created inside a @MainActor method, so it inherits main-actor context. It awaits the HealthKit actor’s save(drink:) and then drinksUpdated(). The handle is not stored and nothing cancels it. Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:217
Task creation (init) Task { await load() } in private init() Starts the initial disk load and HealthKit refresh. The handle is not stored and nothing cancels it. Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:251
Task creation (UI) Plain Task {} in .onChange(of: scenePhase) When the scene becomes active: request authorization, then load new data. The source does not annotate the closure’s actor; the handle is not stored. Updated/Coffee Tracker WatchKit Extension/ContentView.swift:34
Task creation (background refresh) Plain Task {} in handle(_:) Awaits loadNewDataFromHealthKit(), then reschedules and calls setTaskCompletedWithSnapshot(success) inside the task. WatchKit’s background task is completed only after the async work finishes. The handle is not stored. Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:31, Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:44
Framework async callbacks async CLKComplicationDataSource methods ClockKit calls async requirements such as currentTimelineEntry(for:). These replace the withHandler: versions in Starting Point. They read CoffeeData synchronously through lazy var data. ComplicationController has no actor annotation in source. Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift:38, Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift:134
Scheduling/liveness WKExtension.scheduleBackgroundRefresh(withPreferredDate:userInfo:) Requests a refresh 15 minutes ahead with a completion-handler callback that only logs. This callback API was not migrated. Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:81
Starting Point queue DispatchQueue(label: "Background Queue", qos: .userInitiated) A private serial queue for save and load. save() runs synchronously when the app is in the background, otherwise with background.async. Starting Point/Coffee Tracker WatchKit Extension/CoffeeData.swift:25, Starting Point/Coffee Tracker WatchKit Extension/CoffeeData.swift:193
Starting Point main hand-back DispatchQueue.main.async load() hops back to the main queue to assign currentDrinks. HealthKitController does the same before mutating the model, and updateModel checks the hop at runtime with assert(Thread.main == Thread.current, …). Starting Point/Coffee Tracker WatchKit Extension/CoffeeData.swift:224, Starting Point/Coffee Tracker WatchKit Extension/HealthKitController.swift:145, Starting Point/Coffee Tracker WatchKit Extension/HealthKitController.swift:229

Not present in either variant: Task.detached, TaskGroup, AsyncSequence/for await, MainActor.run, nonisolated, Sendable annotations, locks, and RunLoop scheduling.

Reference code

Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:97 — queryHealthKit()

private func queryHealthKit() async throws -> ([HKSample]?, [HKDeletedObject]?, HKQueryAnchor?) {
    return try await withCheckedThrowingContinuation { continuation in
        // ...
        let query = HKAnchoredObjectQuery(
            type: caffeineType,
            predicate: datePredicate,
            anchor: anchor,
            limit: HKObjectQueryNoLimit) { (_, samples, deletedSamples, newAnchor, error) in

            // When the query ends, check for errors.
            if let error = error {
                continuation.resume(throwing: error)
            } else {
                continuation.resume(returning: (samples, deletedSamples, newAnchor))
            }

        }
        store.execute(query)
    }
}

Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:201 — addDrink(mgCaffeine:onDate:): synchronous main-actor mutation first, then an unretained task for the effects

public func addDrink(mgCaffeine: Double, onDate date: Date) {
    // ...
    currentDrinks = drinks

    // Save drink information to HealthKit.
    Task {
        await self.healthKitController.save(drink: drink)
        await self.drinksUpdated()
    }
}

Observed design shift: Starting Point saved from a didSet observer on currentDrinks. Updated removes the observer and calls await drinksUpdated() explicitly after each mutation (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:219, Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:241, Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:263). A property observer cannot await, so the save effect moved into the async methods (inference).

Task lifetime: no Task handle is retained, so none can be cancelled. Each task runs until its awaited work completes. Inference: this is acceptable here because every task is short-lived I/O, but no owner can stop the work when the scene changes.

State propagation, frameworks, and dependencies

Evidence limit: an import proves a source-level compilation dependency at the cited line; it does not prove runtime use, architectural adoption, or whether a Swift package is a direct application dependency.

Category Mechanism or module Verified role Evidence
State propagation @Published public private(set) var currentDrinks The model publishes changes and keeps write access to itself. Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:114
State propagation .environmentObject(data) / @EnvironmentObject ContentView injects the shared model. CoffeeTrackerView and DrinkListView read it, and the sheet re-injects it explicitly. Updated/Coffee Tracker WatchKit Extension/ContentView.swift:24, Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerView.swift:13, Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerView.swift:46
State propagation @Environment(\.scenePhase) + onChange Scene-phase changes start HealthKit refresh (active) or schedule background refresh (background). Updated/Coffee Tracker WatchKit Extension/ContentView.swift:16, Updated/Coffee Tracker WatchKit Extension/ContentView.swift:51
Framework push CLKComplicationServer.reloadTimeline(for:) After every model change, drinksUpdated() asks ClockKit to reload all active complications. Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:120
Combine scheduling None No receive(on:), subscribe(on:), or import Combine. @Published is used only through SwiftUI. Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:8
Framework import SwiftUI, ClockKit, os Compilation dependencies of CoffeeData.swift. Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:9
Framework import HealthKit Compilation dependency of the HealthKit actor. Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:9
Framework import WatchKit Compilation dependency of the extension delegate. Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:8
Package declaration None The bundle contains no Package.swift; the targets use system frameworks only. Updated/Coffee Tracker.xcodeproj/project.pbxproj:249

Class and protocol design

Reference code

Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift:10 — ComplicationController

class ComplicationController: NSObject, CLKComplicationDataSource {

    // The Coffee Tracker app's data model
    lazy var data = CoffeeData.shared

    // ...
    // Return the current timeline entry.
    func currentTimelineEntry(for complication: CLKComplication) async -> CLKComplicationTimelineEntry? {
        createTimelineEntry(forComplication: complication, date: Date())
    }
    // ...
}
Type Responsibility Depends on
CoffeeTrackerApp @main scene and delegate adaptor ContentView, ExtensionDelegate
ContentView Root wrapper: injects the model and reacts to scene phase CoffeeData.shared, HealthKitController
CoffeeTrackerView, DrinkListView Display caffeine state; record a drink CoffeeData (environment)
CoffeeData Main-actor app state, derived caffeine math, effect orchestration CoffeeDataStore, HealthKitController, CLKComplicationServer
CoffeeDataStore Actor-isolated plist persistence with change detection PropertyListEncoder/Decoder, FileManager
HealthKitController Actor-isolated HealthKit authorization, anchored query, and save HKHealthStore, CoffeeData (weak)
ComplicationController ClockKit timeline and template construction CoffeeData.shared
ExtensionDelegate WatchKit background-task dispatch CoffeeData.shared, scheduleBackgroundRefreshTasks()
Drink, DrinkType Hashable, Codable value record; CaseIterable drink catalog Foundation

No local protocol is declared. All protocol conformances are to framework protocols, so the design is not protocol-oriented.

Access control

Symbol Access Verified effect Likely rationale
CoffeeDataStore (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:14) private (file scope) Only CoffeeData.swift can name the persistence actor. Inference: persistence is an implementation detail of the model.
currentDrinks (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:114) public private(set) Readable everywhere, writable only inside CoffeeData. Starting Point declared it public var, and HealthKitController assigned model?.currentDrinks directly (Starting Point/Coffee Tracker WatchKit Extension/HealthKitController.swift:248). Inference: forces HealthKit changes through the updateModel async method. In an app target, public adds nothing beyond internal.
drinksUpdated() (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:116) private Only CoffeeData can trigger the reload-and-save effect. Inference: keeps effect sequencing inside the owner.
init() (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:248) private Prevents any second CoffeeData instance. Enforces the singleton.
model (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:34) private weak var Only the actor reads it; it does not retain the model. Breaks the owner/back-reference cycle.
HealthKit globals (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:16) file-scope private let HKHealthStore and the types are visible only in this file and sit outside actor state. Inference: immutable shared configuration.
removeOutdatedDrinks() (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:277) fileprivate extension method Only CoffeeData.swift can call this Array<Drink> helper. Inference: avoids widening the API of Array.

Reference code

Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:14 — CoffeeDataStore

private actor CoffeeDataStore {
    // ...
    // Use this value to determine whether you have changes that can be saved to disk.
    private var savedValue: [Drink] = []

    // Begin saving the drink data to disk.
    func save(_ currentDrinks: [Drink]) {
        // ...
    }
    // ...
}

Unmarked declarations elsewhere are implicit Swift internal, which in this single-target app means visible to every file in the extension.

Logic ownership and placement

Logic Owning type or file Placement rationale
Caffeine half-life math Drink.caffeineRemaining(at:) (Updated/Coffee Tracker WatchKit Extension/Drink.swift:31) Pure value computation lives on the value type.
Aggregates and color thresholds CoffeeData (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:143) Derived from published state, so views and complications share one implementation.
Merge of HealthKit changes CoffeeData.updateModel(newDrinks:deletedDrinks:) (Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:224) The only path from HealthKitController into model state. Observed: the guard returns unless both inputs are non-empty, and the filter keeps drinks whose UUID is in deletedDrinks. Starting Point has the same logic, so the migration preserved it.
Disk encode/decode and dirty check CoffeeDataStore Isolated with the savedValue it compares against.
Sample conversion and anchor persistence HealthKitController Kept with the HealthKit query that produces the samples.
Background refresh scheduling scheduleBackgroundRefreshTasks() free function (Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:69) Shared by ContentView and ExtensionDelegate without a type.
Template construction ComplicationController private factories Kept with the ClockKit data source.

Design patterns

Pattern Source evidence Purpose or tradeoff
Actor isolation Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:14 CoffeeDataStore; Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:26 HealthKitController Replaces the serial DispatchQueue with compiler-checked isolation of savedValue, anchor, and isAuthorized. The cost is an await on every call.
Publisher-backed observable state Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:114 @Published with private(set) keeps one writer and many SwiftUI readers. @MainActor keeps those writes on the main actor.
SwiftUI environment injection Updated/Coffee Tracker WatchKit Extension/ContentView.swift:24 Views receive the model without initializer parameters.
Delegate or data-source callbacks Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:12; Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift:10 WatchKit and ClockKit call into app objects named in Info.plist. In Updated, the data-source requirements are async functions instead of completion handlers.
Singleton Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:105 and private init() at Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift:248 Gives framework-instantiated objects a shared model. Tradeoff: hidden global dependency; previews also use CoffeeData.shared.
Callback-to-async continuation bridge Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:98 Adapts the callback-only HKAnchoredObjectQuery into async throws. Other HealthKit calls use SDK async overloads directly (Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:88, Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift:185).

Main application flow

Reference code

Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift:20 — handle(_:)

    func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
        // ...
        for task in backgroundTasks {
            // ...
            switch task {
            // Handle background refresh tasks.
            case let backgroundTask as WKApplicationRefreshBackgroundTask:

                Task {
                    // Check for updates from HealthKit.
                    let model = CoffeeData.shared

                    let success = await model.healthKitController.loadNewDataFromHealthKit()

                    if success {
                        // Schedule the next background update.
                        scheduleBackgroundRefreshTasks()
                        self.logger.debug("Background Task Completed Successfully!")
                    }

                    // Mark the task as ended, and request an updated snapshot, if necessary.
                    backgroundTask.setTaskCompletedWithSnapshot(success)
                }
            // ...
            default:
                task.setTaskCompletedWithSnapshot(false)
            }
        }
    }

Naming conventions

  • Types: role suffixes describe framework roles. …App is the entry, …View a SwiftUI view, …Controller a framework-facing collaborator (HealthKitController is an actor and ComplicationController a data source, not view controllers), and …Delegate the WatchKit delegate. CoffeeDataStore is a private persistence actor, not an app-wide state store.
  • Data: CoffeeData is the observable model, Drink a record, DrinkType a catalog enum.
  • Methods: verb-first commands (addDrink(mgCaffeine:onDate:), requestAuthorization(), loadNewDataFromHealthKit(), scheduleBackgroundRefreshTasks()). Past-tense effect hooks (drinksUpdated()). Unit-bearing names (mgCaffeine, currentMGCaffeineString, totalCupsToday).
  • Migration naming: the async API keeps the synchronous name and drops the completionHandler: parameter. The ClockKit async requirements drop the get…withHandler: prefix, so getCurrentTimelineEntry(for:withHandler:) becomes currentTimelineEntry(for:) (Starting Point/Coffee Tracker WatchKit Extension/ComplicationController.swift:39).
  • Files: one primary type per file, named after the type. The exception is CoffeeData.swift, which also hosts the private actor and a fileprivate Array extension.

Architecture takeaways

  • Isolate state, not threads. The migration replaced one serial queue and manual DispatchQueue.main hops with three explicit isolation domains: the main actor for UI state, CoffeeDataStore for disk state, and HealthKitController for HealthKit state.
  • Make the UI model @MainActor and narrow its writers with private(set). This turned a runtime assert(Thread.main == …) into a compile-time requirement to await into the model.
  • Use SDK async overloads where they exist, and wrap the remaining callback API with one checked continuation that resumes on every path.
  • Effects previously hidden in didSet became explicit await drinksUpdated() calls, so the ordering of mutation, complication reload, and save is visible at each call site.
  • Unstructured Task {} calls are fire-and-forget here. No owner retains or cancels them. A larger app would need an owner for task lifetime.

Source map

Source file Relevant symbols
Updated/Coffee Tracker WatchKit Extension/CoffeeData.swift CoffeeDataStore, CoffeeData, shared, currentDrinks, drinksUpdated(), addDrink, updateModel, load(), removeOutdatedDrinks()
Updated/Coffee Tracker WatchKit Extension/HealthKitController.swift HealthKitController, model, anchor, requestAuthorization(), queryHealthKit(), loadNewDataFromHealthKit(), save(drink:)
Updated/Coffee Tracker WatchKit Extension/ExtensionDelegate.swift ExtensionDelegate, handle(_:), scheduleBackgroundRefreshTasks()
Updated/Coffee Tracker WatchKit Extension/ContentView.swift ContentView, scenePhase, .environmentObject
Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerView.swift CoffeeTrackerView, @EnvironmentObject coffeeData, showDrinkList
Updated/Coffee Tracker WatchKit Extension/CoffeeTrackerApp.swift CoffeeTrackerApp, @WKExtensionDelegateAdaptor
Updated/Coffee Tracker WatchKit Extension/ComplicationController.swift ComplicationController, data, async data-source methods
Updated/Coffee Tracker WatchKit Extension/HostingController.swift HostingController
Updated/Coffee Tracker WatchKit Extension/Drink.swift Drink, caffeineRemaining(at:)
Updated/Coffee Tracker WatchKit Extension/Info.plist CLKComplicationPrincipalClass, WKExtensionDelegateClassName
Updated/Coffee Tracker.xcodeproj/project.pbxproj Targets, SWIFT_VERSION, WATCHOS_DEPLOYMENT_TARGET
Starting Point/Coffee Tracker WatchKit Extension/CoffeeData.swift background queue, save(), load(), DispatchQueue.main.async
Starting Point/Coffee Tracker WatchKit Extension/HealthKitController.swift Completion-handler API, DispatchQueue.main.async, updateModel assert
Starting Point/Coffee Tracker WatchKit Extension/ComplicationController.swift getCurrentTimelineEntry(for:withHandler:)