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 APIRequest with its own Response type. No stringly-typed dispatch.
  • Single error typeAPIError covers 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 a URLSessionProtocol fake or a URLProtocol-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, SubscriptionWatcher are actors.
  • StoreKit’s Transaction.updates is consumed in App.task { } so it runs for the app’s lifetime.

Next: Implementation guide