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
HKWorkoutrecord. 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. HealthQueryStreamis an actor.- SwiftData
ModelContextis bound to the main actor; background imports use a separateModelActor(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