FitTrack — Architecture

High-level diagram

+--------------------+        +---------------------+
|  iOS app (SwiftUI) |        |  watchOS app        |
|  - Dashboard       |        |  - Workout sessions |
|  - Workout list    |        |  - Live HR/distance |
|  - Charts          |        |  - Complications    |
+----------+---------+        +----------+----------+
           |                             |
           v                             v
+--------------------+        +---------------------+
|  SwiftData store   |<------>|  HealthKit          |
|  (CloudKit-backed) |        |  (HKWorkout,        |
|                    |        |   HKQuantitySample) |
+--------------------+        +---------------------+
           ^
           |
           v
+--------------------+
|  CloudKit private  |
|  DB (auto via      |
|  ModelConfig)      |
+--------------------+

Two data systems intentionally:

  • HealthKit is the source of truth for samples (heart rate, steps, energy) and for the canonical HKWorkout record. It syncs through Apple Health, not us.
  • SwiftData is our projection — workout metadata (notes, photos, tags) the user attaches that HealthKit doesn’t model. It syncs via CloudKit.

This separation is the most interview-worthy decision in FitTrack. We could put everything in SwiftData, but then user-deleted-FitTrack-data wouldn’t reach Apple Health. We could put everything in HealthKit, but HealthKit can’t store our custom notes/tags. So we keep both, linked by HKWorkout.uuid.

Module layout

FitTrack/
  iOSApp/                            # iOS target
  WatchApp/                          # watchOS target
  WidgetExtension/                   # widget + complications
  Packages/
    FitTrackCore/                    # models, errors, no UI
    HealthKitBridge/                 # HealthKit query streams
    Persistence/                     # SwiftData container + queries
    FitTrackUI/                      # SwiftUI views shared across iOS targets
    FitTrackWatchUI/                 # watchOS-specific views

SwiftData schema

@Model
final class Workout {
    @Attribute(.unique) var id: UUID         // matches HKWorkout.uuid
    var activityType: ActivityType            // enum, custom
    var startDate: Date
    var duration: TimeInterval
    var notes: String?
    var photoData: Data?                      // small, < 200 KB; large goes to CK asset
    @Relationship(deleteRule: .cascade) var tags: [WorkoutTag]
    var createdAt: Date

    init(id: UUID = UUID(), activityType: ActivityType,
         startDate: Date, duration: TimeInterval) {
        self.id = id
        self.activityType = activityType
        self.startDate = startDate
        self.duration = duration
        self.createdAt = .now
    }
}

@Model
final class WorkoutTag {
    var name: String
    var createdAt: Date
    init(name: String) { self.name = name; self.createdAt = .now }
}

Configured for CloudKit:

let config = ModelConfiguration(
    cloudKitDatabase: .private("iCloud.com.yourorg.fittrack")
)
let container = try ModelContainer(for: Workout.self, WorkoutTag.self, configurations: config)

CloudKit container must have the schema deployed to Production before App Store submission.

HealthKit query stream pattern

The interesting design: wrap HealthKit’s awkward HKObserverQuery + HKAnchoredObjectQuery boilerplate behind an AsyncSequence.

public actor HealthQueryStream<Sample: HKSample> {
    public typealias Element = [Sample]

    private let store: HKHealthStore
    private let sampleType: HKSampleType
    private let predicate: NSPredicate?
    private var anchor: HKQueryAnchor?

    public init(store: HKHealthStore, sampleType: HKSampleType, predicate: NSPredicate? = nil) {
        self.store = store
        self.sampleType = sampleType
        self.predicate = predicate
    }

    public func samples() -> AsyncThrowingStream<[Sample], Error> {
        AsyncThrowingStream { continuation in
            let query = HKObserverQuery(sampleType: sampleType, predicate: predicate) { [weak self] _, _, error in
                if let error { continuation.finish(throwing: error); return }
                Task { [weak self] in
                    guard let self else { return }
                    if let newSamples = try? await self.fetchSinceAnchor() {
                        continuation.yield(newSamples)
                    }
                }
            }
            store.execute(query)
            continuation.onTermination = { _ in
                self.store.stop(query)
            }
        }
    }

    private func fetchSinceAnchor() async throws -> [Sample] {
        // HKAnchoredObjectQuery with self.anchor; update self.anchor on completion
        // ...
    }
}

Now callers write:

let stream = HealthQueryStream<HKQuantitySample>(store: store, sampleType: .quantityType(forIdentifier: .heartRate)!)
for try await samples in stream.samples() {
    // process new heart rate samples
}

That collapses the typical 200-line “implement two query types and route between them” pattern into 5 lines. It’s the single piece of code I’d lead with in an interview.

ADRs

ADR-001: SwiftData + CloudKit over Core Data + CloudKit

Status: Accepted.

Context: We need a local store that syncs to CloudKit. SwiftData (iOS 17+) is the new Apple recommendation; Core Data is legacy.

Decision: SwiftData with ModelConfiguration(cloudKitDatabase: .private(...)).

Consequences: iOS 17+ floor (acceptable). Less battle-tested than Core Data; we accept some sharp edges (schema migration is harder; debugging is sparser). Future-proof: this is where Apple is investing.

ADR-002: HealthKit is the source of truth for samples

Status: Accepted.

Context: Workouts produce both metadata (notes, tags — our model) and samples (heart rate, energy — Apple’s model). We must pick a system of record per category.

Decision: HealthKit owns samples + the canonical HKWorkout; SwiftData owns metadata linked by UUID.

Consequences: Two systems to keep in sync. Deletion is one-way (we delete from SwiftData; Apple Health requires the user to delete there). Trade is necessary: HealthKit can’t hold our notes; SwiftData can’t replace Apple Health.

ADR-003: AsyncSequence wrapper over raw HealthKit observers

Status: Accepted.

Context: HealthKit observers + anchored queries are 200+ lines of boilerplate per sample type.

Decision: A single HealthQueryStream actor exposes new samples as AsyncThrowingStream.

Consequences: One place to fix HealthKit bugs. Easier to test (mock the stream). Slight performance overhead from the actor hop — negligible compared to HealthKit’s own query cost.

ADR-004: Watch app starts workouts; iPhone displays them

Status: Accepted.

Context: Either device can host a workout session. Splitting roles makes the UX coherent.

Decision: Watch is the workout-session controller (HR sensor lives there). iPhone shows the running workout via Live Activity and the history afterward.

Consequences: User cannot start a workout from iPhone (they can only log one retroactively, which is different). Acceptable, matches Apple’s own design.

Threading model

  • All SwiftUI views on @MainActor.
  • HealthQueryStream is an actor.
  • SwiftData ModelContext is bound to the main actor; background imports use a separate ModelActor (Swift 5.9+).
  • Live Activity updates are throttled to once per 5 seconds on the iPhone side.

Error handling

  • HealthKit-denied: graceful fallback to manual entry; never crash.
  • CloudKit account unavailable: SwiftData operates locally; sync resumes when account returns. Non-modal status indicator.
  • Watch–iPhone connectivity lost: Watch session continues independently; sync on reconnect.

Next: Implementation guide