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?
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.purchaseand 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
Appor 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… →