SkyWatch — Architecture
High-level diagram
+----------------------+ +----------------------+
| SwiftUI views | | Widget extension |
| (HomeView, MapView, | | (Lock Screen, |
| AlertView) | | Home Screen) |
+----------+-----------+ +----------+-----------+
| |
v v
+----------------------+ +----------------------+
| AppState | | TimelineProvider |
| (@Observable) | | (reads cache only) |
+----------+-----------+ +----------+-----------+
| |
v |
+----------------------+ |
| WeatherService |<------------------+
| (cache + WeatherKit)|
+----------+-----------+
|
v
+----------------------+ +----------------------+
| WeatherKit API | | CloudKit private DB |
| (Apple, throttled) | | (SavedLocation, |
| | | user-scoped) |
+----------------------+ +----------------------+
Module layout (SPM packages)
SkyWatch/
App/ # main app target (iOS)
Widget/ # widget extension target
Packages/
SkyWatchCore/ # models, errors, no UIKit/SwiftUI
WeatherService/ # WeatherKit + cache
LocationStore/ # CloudKit + local cache
SkyWatchUI/ # SwiftUI views, reusable design system
AppIntentsKit/ # App Intents (separate target so the
# intent metadata builds independently)
Why split this way:
SkyWatchCoreis pure-Swift. Both the app and the widget can depend on it without dragging UI frameworks into the widget binary.WeatherServiceandLocationStoreare independently testable. Mock each by injecting protocol-conforming fakes.SkyWatchUIis the only target that imports SwiftUI. Keep it lean.
Data flow — typical home view load
HomeView.task { ... }callsappState.refresh(for: location).AppStatecallsWeatherService.weather(for: location).WeatherServicechecksWeatherCache(in-memory + on-disk).- Cache hit (entry < TTL): return immediately.
- Cache miss: call
WeatherKit.WeatherService.shared.weather(for: location)(Apple’s API), persist the result to cache with a TTL, return. AppStatepublishes the result;HomeViewre-renders.
The cache layer (the interesting bit)
This is the single most interview-worthy piece of SkyWatch. WeatherKit charges per call beyond the free quota, so designing the cache is real engineering, not bookkeeping.
public actor WeatherCache {
public struct Key: Hashable {
public let coordinate: CLLocationCoordinate2D
public let kinds: Set<WeatherQuery.Kind>
}
private struct Entry {
let weather: Weather
let storedAt: Date
}
private var memory: [Key: Entry] = [:]
private let diskURL: URL
private let ttl: TimeInterval // e.g. 30 min for currentWeather, 6h for daily
public func get(_ key: Key) -> Weather? {
if let e = memory[key], Date().timeIntervalSince(e.storedAt) < ttl {
return e.weather
}
// try disk
return nil
}
public func set(_ key: Key, weather: Weather) {
memory[key] = Entry(weather: weather, storedAt: Date())
// persist to disk via Codable
}
}
Key design choices:
- Per-kind TTL:
currentWeatheris 30 min;hourlyForecastis 1 h;dailyForecastis 6 h;minuteForecastis 5 min. - In-memory + disk: in-memory for the running app, disk so the widget can read without a fresh API call.
- Coordinate quantization: bucket coordinates to ~3 decimals (~110 m) so users near the same place share cache entries.
- Stale-while-revalidate: home screen shows cached data immediately, kicks off a background refresh if data is older than 50% of TTL.
Widget timeline strategy
struct SkyWatchProvider: AppIntentTimelineProvider {
func timeline(for config: ConfigIntent, in context: Context) async -> Timeline<Entry> {
let cached = await SharedCache.read(for: config.locationID)
let entries = (0..<4).map { i in
Entry(date: .now.addingTimeInterval(Double(i) * 15 * 60),
weather: cached)
}
return Timeline(entries: entries, policy: .after(.now.addingTimeInterval(15 * 60)))
}
}
- Widget reads from a shared App Group cache; never calls WeatherKit itself.
- Timeline produces 4 entries (1 hour of 15-minute intervals), refreshes after the last.
- Background refresh in the main app updates the shared cache via
BGAppRefreshTask.
CloudKit schema
| Record type | Field | Type | Notes |
|---|---|---|---|
SavedLocation | name | String | user-editable |
latitude | Double | ||
longitude | Double | ||
order | Int64 | for ordering | |
createdAt | Date | system |
- Database: Private DB only. No public records.
- Subscription: silent push subscription on
SavedLocationso other devices auto-sync. - Conflict resolution: server change tag mismatch → reload + merge by
createdAt; for the same record edited concurrently, last-write-wins onnameandorder.
ADRs (Architecture Decision Records)
ADR-001: WeatherKit, not OpenWeatherMap
- Status: Accepted
- Context: Need a weather API. Free tier matters. WeatherKit is free for 500K calls/month, integrates natively, and meets Apple’s review preference for first-party APIs.
- Decision: Use WeatherKit.
- Consequences: Locked to iOS 16+ (acceptable). Must include attribution per Apple’s brand guidelines. Apple Developer Program ($99/yr) required.
ADR-002: CloudKit private DB, not SwiftData with CloudKit
- Status: Accepted
- Context: Need to sync saved locations across user’s devices. SwiftData+CloudKit is convenient but obscures the conflict resolution layer.
- Decision: Direct CKRecord + CKDatabase, with a thin
LocationStoreactor wrapping it. - Consequences: More code than SwiftData+CloudKit; better control. The capstone proves I understand CloudKit primitives, which is the goal.
ADR-003: SPM modules, not a single target
- Status: Accepted
- Context: Widget extension cannot depend on SwiftUI app target.
- Decision: Split into SPM packages so widget and app share core/service code without UI dependencies.
- Consequences: Slightly more Package.swift maintenance; massive testability win.
ADR-004: Local notifications, not APNs
- Status: Accepted
- Context: Need to alert users to severe weather. APNs requires a server.
- Decision: Background app refresh polls WeatherKit; if a new alert is found, schedule a
UNUserNotificationCenterlocal notification. - Consequences: No real-time alerts (refresh interval is iOS-controlled, ~15 min minimum). Acceptable tradeoff for capstone scope. Note this in interview answers as a known limitation with a clear remediation path (APNs + server).
Threading model
@MainActor:AppState, all SwiftUI views (default).- Actors:
WeatherCache,LocationStore,WeatherService. - Background tasks:
BGAppRefreshTaskfor widget cache warming and alert polling, registered inInfo.plistunderBGTaskSchedulerPermittedIdentifiers.
Error handling philosophy
- Recoverable errors (network down, WeatherKit rate-limited, CloudKit quota): show cached data with a non-modal status indicator.
- Unrecoverable errors (Apple ID signed out, WeatherKit entitlement missing): show a full-screen error with action.
- Programmer errors (force-unwraps): there are zero in this codebase; SwiftLint rule enforces.
Next: Implementation guide