How do you structure a StoreKit 2 integration: which object owns products, entitlements, the updates listener and restore, and where does the seam for testing go?

28.7.1 · L5 · Interview

Predict first

Does the store object (catalogue, entitlements, updates listener, restore) compile against the iOS SDK?

import StoreKit
import Observation

protocol StoreBackend: Sendable {
    func products(for ids: [String]) async throws -> [Product]
    func entitledProductIDs() async -> Set<String>
}
struct LiveBackend: StoreBackend {
    func products(for ids: [String]) async throws -> [Product] { try await Product.products(for: ids) }
    func entitledProductIDs() async -> Set<String> {
        var ids = Set<String>()
        for await result in Transaction.currentEntitlements {
            if case .verified(let t) = result, t.revocationDate == nil { ids.insert(t.productID) }
        }
        return ids
    }
}

@MainActor @Observable final class Store {
    private(set) var products: [Product] = []
    private(set) var missingIDs: [String] = []
    private(set) var entitled: Set<String> = []
    private(set) var isLoading = false
    private let ids: [String]
    private let backend: StoreBackend
    @ObservationIgnored private var updates: Task<Void, Never>?

    init(ids: [String], backend: StoreBackend = LiveBackend()) {
        self.ids = ids; self.backend = backend
        updates = Task { [weak self] in
            for await result in Transaction.updates {
                guard case .verified(let t) = result else { continue }
                await self?.refreshEntitlements()
                await t.finish()
            }
        }
    }
    deinit { updates?.cancel() }

    func load() async {
        isLoading = true; defer { isLoading = false }
        do {
            let found = try await backend.products(for: ids)
            products = found.sorted { $0.price < $1.price }
            missingIDs = ids.filter { id in !found.contains { $0.id == id } }
        } catch { products = [] }
        await refreshEntitlements()
    }
    func refreshEntitlements() async { entitled = await backend.entitledProductIDs() }
    func restore() async throws { try await AppStore.sync(); await refreshEntitlements() }
    func isUnlocked(_ id: String) -> Bool { entitled.contains(id) }
}
  • Yes, it compiles
  • No: Transaction.updates is not available on iOS
  • No: AppStore.sync cannot be called from a class

Mental model. A shop has one till. Customers (screens) ask the till what is for sale and whether they have paid; they never open the safe, so a payment made at the other door still shows up at the same till.

Put everything StoreKit-related behind one store object created at launch and shared with the UI (an @Observable class on the main actor, or an actor with an observable wrapper). It has four jobs. Load the catalogue (Product.products(for:) with your product ids). Know what is owned (rebuild a set of entitled product ids from Transaction.currentEntitlements, ignoring revoked ones). Listen for changes (a task over Transaction.updates that refreshes the entitlements and then finishes the transaction; start it in init so it runs from launch, and cancel it in deinit). Offer restore (AppStore.sync(), then refresh). Views only read products and isUnlocked(_:) and call purchase or restore; they never touch StoreKit directly, so a purchase made on another device, a renewal, a refund or an Ask to Buy approval all flow through the same path and the UI simply re-renders.

Two design choices pay off. First, a small backend protocol (products(for:) and entitledProductIDs()) with a live implementation over StoreKit lets you test the store object with a fake (see the testing question below), because Product and Transaction cannot be constructed in tests. Second, keep decisions out of the views: the store reports missingIDs (ids you asked for that the store did not return, a sign of a configuration problem), isLoading and a load failure, so the UI can show a retry state instead of an empty screen. The check below compiles the skeleton against the iOS SDK (no store is contacted); the real behaviour still has to be exercised with a StoreKit configuration file and the sandbox.

The compile check proves the API shapes only; no store was contacted.

Common traps

  • Starting the updates listener lazily, when the paywall opens. Renewals, refunds and approvals arrive when the paywall is closed; start it at launch.
  • Letting every screen call Product.purchase and read entitlements itself. State diverges and nothing is testable.

They might follow up with

  • Where does the store object live in SwiftUI? Create it once (in the App or a root view) and pass it through the environment.
  • What about a backend that also needs to know? After a verified purchase or update, send the signed transaction to your server (Q1005) from the same place.

Related questions

  • 28.7.2 How do you fetch products and build the catalogue screen: ids, ordering, missing products, period labels and…
  • 28.7.6 How do you unit-test the purchase and entitlement logic when Product and Transaction cannot be created in a…
  • 28.1.4 Why start a Transaction.updates listener at launch, and what do finish() and idempotent delivery guarantee?
  • 28.3.1 How do you compute what a user is entitled to, and which transactions are excluded from…
  • 28.3.2 How do restore, Family Sharing and refunds work in StoreKit 2, and how should the app remove access?

How do you fetch products and build the catalogue screen: ids… →