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:

  • SkyWatchCore is pure-Swift. Both the app and the widget can depend on it without dragging UI frameworks into the widget binary.
  • WeatherService and LocationStore are independently testable. Mock each by injecting protocol-conforming fakes.
  • SkyWatchUI is the only target that imports SwiftUI. Keep it lean.

Data flow — typical home view load

  1. HomeView.task { ... } calls appState.refresh(for: location).
  2. AppState calls WeatherService.weather(for: location).
  3. WeatherService checks WeatherCache (in-memory + on-disk).
  4. Cache hit (entry < TTL): return immediately.
  5. Cache miss: call WeatherKit.WeatherService.shared.weather(for: location) (Apple’s API), persist the result to cache with a TTL, return.
  6. AppState publishes the result; HomeView re-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: currentWeather is 30 min; hourlyForecast is 1 h; dailyForecast is 6 h; minuteForecast is 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 typeFieldTypeNotes
SavedLocationnameStringuser-editable
latitudeDouble
longitudeDouble
orderInt64for ordering
createdAtDatesystem
  • Database: Private DB only. No public records.
  • Subscription: silent push subscription on SavedLocation so other devices auto-sync.
  • Conflict resolution: server change tag mismatch → reload + merge by createdAt; for the same record edited concurrently, last-write-wins on name and order.

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 LocationStore actor 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 UNUserNotificationCenter local 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: BGAppRefreshTask for widget cache warming and alert polling, registered in Info.plist under BGTaskSchedulerPermittedIdentifiers.

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