ShopKit — Architecture
High-level diagram
+---------------------+ +---------------------+
| SwiftUI views | | StoreKit 2 |
| - Home / Article |<------>| Transaction stream |
| - Paywall | | Product.purchase() |
| - Settings | +----------+----------+
+----------+----------+ |
| v
v +---------------------+
+---------------------+ | SubscriptionStatus |
| AppState |<-------| (@Observable) |
+----------+----------+ +---------------------+
|
v
+---------------------+ +---------------------+
| APIClient |<------>| AuthProvider |
| (typed errors) | | (Keychain-backed) |
+----------+----------+ +---------------------+
|
v
+---------------------+
| Backend (Express |
| + Postgres) |
| /articles, /me |
+---------------------+
Module layout
ShopKit/
App/ # main iOS target
Packages/
ShopKitCore/ # models, errors
ShopKitAPI/ # APIClient, request types
ShopKitAuth/ # Sign in with Apple + Keychain
ShopKitStore/ # StoreKit 2 wrapper + SubscriptionStatus
ShopKitUI/ # views, design system
The networking layer
Centerpiece of the capstone’s “show me your code” moment. Build it from scratch:
public protocol APIRequest {
associatedtype Response: Decodable
var path: String { get }
var method: HTTPMethod { get }
var query: [URLQueryItem] { get }
var body: Data? { get }
var requiresAuth: Bool { get }
}
public enum APIError: Error, Equatable {
case unauthorized
case notFound
case server(Int, message: String?)
case decoding(any Error)
case transport(URLError)
case cancelled
public static func == (lhs: Self, rhs: Self) -> Bool { /* ... */ }
}
public final class APIClient {
private let base: URL
private let session: URLSession
private let auth: AuthProviding
private let decoder: JSONDecoder
public init(base: URL, session: URLSession, auth: AuthProviding,
decoder: JSONDecoder = .iso8601) {
self.base = base
self.session = session
self.auth = auth
self.decoder = decoder
}
public func send<R: APIRequest>(_ request: R) async throws -> R.Response {
var url = base.appending(path: request.path)
if !request.query.isEmpty {
url = url.appending(queryItems: request.query)
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
urlRequest.httpBody = request.body
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
if request.requiresAuth {
urlRequest.setValue("Bearer \(try await auth.token())",
forHTTPHeaderField: "Authorization")
}
do {
let (data, response) = try await session.data(for: urlRequest)
guard let http = response as? HTTPURLResponse else {
throw APIError.transport(.init(.badServerResponse))
}
switch http.statusCode {
case 200...299:
do { return try decoder.decode(R.Response.self, from: data) }
catch { throw APIError.decoding(error) }
case 401:
if request.requiresAuth, try await auth.refresh() {
return try await send(request) // one retry
}
throw APIError.unauthorized
case 404:
throw APIError.notFound
case 500...599:
throw APIError.server(http.statusCode, message: String(data: data, encoding: .utf8))
default:
throw APIError.server(http.statusCode, message: nil)
}
} catch let error as URLError {
throw error.code == .cancelled ? APIError.cancelled : APIError.transport(error)
}
}
}
Key points:
- Typed requests — every endpoint is a struct conforming to
APIRequestwith its ownResponsetype. No stringly-typed dispatch. - Single error type —
APIErrorcovers every failure mode the UI needs to distinguish. - One-shot 401 retry — refresh token once on 401; if that fails, propagate
unauthorized. Prevents infinite retry loops. - No bare
URLSession.shared— testable by injecting aURLSessionProtocolfake or aURLProtocol-based stub.
The subscription state machine
StoreKit 2 surfaces transaction state, but the UI needs to know one of seven user-facing states:
public enum SubscriptionStatus: Equatable {
case notSubscribed
case inFreeTrial(expires: Date)
case active(expires: Date)
case inBillingRetry(expires: Date)
case inGracePeriod(expires: Date)
case expired
case revoked(reason: RevocationReason)
public var allowsProAccess: Bool {
switch self {
case .active, .inFreeTrial, .inGracePeriod: return true
case .inBillingRetry: return true // Apple recommends honoring during retry
case .notSubscribed, .expired, .revoked: return false
}
}
}
A SubscriptionWatcher actor listens to Transaction.updates and Transaction.currentEntitlements, maps each to a SubscriptionStatus, and publishes via @Observable. The UI observes subscription.status.allowsProAccess to decide gating.
ADRs
ADR-001: Custom networking layer over Alamofire
Building it ourselves is the point of the capstone. URLSession + async/await is enough for our scope. Alamofire would add a dependency for marginal benefit and weaken the interview story.
ADR-002: StoreKit 2, not legacy receipt validation
StoreKit 2 (iOS 15+) gives JWS-signed Transaction values we can trust on-device without a server. We still recommend a server-side App Store Server API check for revenue-critical apps, but for ShopKit’s scope, on-device validation is correct.
ADR-003: Sign in with Apple, no email/password
One-tap. No password DB. No ‘forgot password’ flow. Apple’s brand legitimacy. Required anyway for any app that offers third-party login (we don’t, but it’s still the best default).
ADR-004: Keychain access with .userPresence, not biometric-only
For the auth token, .userPresence (biometric OR passcode) is the right policy — biometric-only locks out users who disabled Face ID. .userPresence requires both: token in Keychain + recent device unlock — strong enough.
ADR-005: GitHub Actions over Xcode Cloud
Both work. We picked GitHub Actions because the readers’ interviewers are more likely to ask about it (more universal CI knowledge), and because GitHub Actions config is in the repo and reviewable. Xcode Cloud is fine if you’re an Apple-only shop; pick what fits.
Test strategy
- APIClient:
URLProtocol-based stub server to test every status code + error path. - SubscriptionWatcher: inject a fake
TransactionStream; assert state machine transitions. - AuthProvider: in-memory Keychain mock; assert read/write/refresh paths.
- UI: snapshot tests for paywall variants; XCUITest for purchase flow against StoreKit Configuration file.
Threading
- All views
@MainActor. APIClient,AuthProvider,SubscriptionWatcherare actors.- StoreKit’s
Transaction.updatesis consumed inApp.task { }so it runs for the app’s lifetime.
Next: Implementation guide