* Add workflow prewarming performance benchmark * Add performance baseline recording and comparison * Move performance baseline recording and checks into XCTest * Declare performance tolerance in tests and exclude legacy project checks * Simplify memory benchmark and run it on each PR * Use published workflow for performance benchmark * Add PR job to record and push performance baselines * Update performance baselines * Run baseline automation tests on PRs and use Fastlane with Xcode 27 * Update performance baselines * Require performance benchmark in PR merge gate --------- Co-authored-by: RCGitBot <dev+RCGitBot@revenuecat.com>
19 KiB
purchases-ios — Development Guidelines
This file provides guidance to AI coding agents when working with code in this repository.
Before changing or reviewing Swift code, read Contributing/SwiftStyleGuide.swift. Check new or modified code against its guidelines. During reviews, report actionable violations alongside correctness and API compatibility issues, even when builds and tests pass.
Project Overview
RevenueCat's official iOS SDK for in-app purchases and subscriptions. Supports iOS, macOS, tvOS, watchOS, and visionOS.
Related repositories:
- Android SDK: https://github.com/RevenueCat/purchases-android
- Flutter SDK: https://github.com/RevenueCat/purchases-flutter
- React Native SDK: https://github.com/RevenueCat/react-native-purchases
- Hybrid Common: https://github.com/RevenueCat/purchases-hybrid-common — shared layer for hybrid SDKs
When implementing features or debugging, check these repos for reference and patterns.
Important: Public API Stability
Do NOT introduce breaking changes to the public API. The SDK is used by thousands of apps.
Safe changes:
- Adding new optional parameters to existing methods
- Adding new classes, methods, or properties
- Bug fixes that don't change method signatures
- Internal implementation changes
Requires explicit approval:
- Removing or renaming public classes/methods/properties
- Changing method signatures (parameter types, required params)
- Changing return types
- Modifying behavior in ways that break existing integrations
Do NOT add new public enum types to the SDK's consumer-facing surface. Adding a case to an existing public enum is a source-breaking change — any consumer with an exhaustive switch will fail to compile. Use structs with static constants or other patterns instead when exposing new option sets or categories. Enums marked @_spi(Internal) (on the same line as the declaration) are exempt because they are not part of the SDK's consumer-facing surface.
This policy is enforced by the no_new_public_enums SwiftLint custom rule defined in .swiftlint.yml. Pre-existing public enums are grandfathered in swiftlint-baseline.json.
Do NOT add new public inits to the SDK's consumer-facing surface. Public initializers are part of the binary API and cannot change without breaking consumers. Keep initializers internal or private, or mark them @_spi(Internal) (on the same line or the line above) when hybrid SDKs need them. Initializers required by Decodable, RawRepresentable and NSCoding are exempt. The rule cannot see an enclosing @_spi(Internal) extension, so inits inside one still need @_spi(Internal) on the init itself.
This policy is enforced by the no_new_public_inits SwiftLint custom rule defined in .swiftlint.yml. Pre-existing public initializers are grandfathered in swiftlint-baseline.json.
Baseline entries are matched by line text, so moving code around does not invalidate them. To accept an intentional new violation, regenerate the baseline with swiftlint lint --write-baseline swiftlint-baseline.json and commit it. Custom rules have fixtures in Tests/SwiftLintCustomRules/Fixtures; when changing a rule, extend its fixture and run ruby Tests/SwiftLintCustomRules/swiftlint_custom_rules_test.rb.
The Tests/APITesters/ targets run in CI to catch unintended API changes. The api/*.swiftinterface files track the public API surface. If API tests fail, you've likely broken the public API.
Objective-C Compatibility
Many core SDK classes are exposed to Objective-C and must stay compatible. Key rules:
NSObjectsubclasses (Purchases,CustomerInfo,EntitlementInfo,StoreProduct,StoreTransaction, etc.) must remain@objc-compatible. Don't add Swift-only types (e.g., generics,asyncwithout a completion-handler wrapper, Swift enums without@objc, default parameter values) to their public API without providing an Obj-C equivalent.@objc(RC...)prefixed names are used for Obj-C class names (e.g.,@objc(RCPurchases),@objc(RCCustomerInfo)). Don't remove or change these.- New public properties/methods on
@objc-exposed classes must be marked@objcunless there's a deliberate reason to exclude them (document why). - Enums exposed to Obj-C use
@objcwithIntraw values. Swift-only enums with associated values or string raw values can't be used from Obj-C. - Both Swift and Obj-C API testers exist in
Tests/APITesters/. When modifying public API on an@objcclass, update bothSwiftAPITesterandObjcAPITestertargets. - Don't break existing Obj-C callers — if a method is currently callable from Obj-C, it must remain so.
Common Development Commands
Quick reference for the most common operations:
swift build # Build via SPM
swift test # Run unit tests via SPM
tuist generate # Generate Tuist workspace (preferred)
swiftlint # Run linter
swiftlint --fix # Auto-fix lint issues
bundle exec fastlane test_ios # Run iOS tests via Fastlane
bundle exec fastlane run_api_tests # Verify public API surface
For the full set of build, test, Tuist, and Fastlane commands, see:
Contributing/CONTRIBUTING.md— environment setup, workflow, style guideContributing/DEVELOPMENT.md— Tuist workspace generation, targets, troubleshootingfastlane/README.md— complete list of available Fastlane lanes
Project Architecture
Module Structure
This is a multi-target Swift project supporting iOS, macOS, tvOS, watchOS, and visionOS:
RevenueCat(Sources/) — Core SDK: API, business logic, networking, StoreKit abstractionsRevenueCat_CustomEntitlementComputation(CustomEntitlementComputation/→ symlink toSources/) — Same source withENABLE_CUSTOM_ENTITLEMENT_COMPUTATIONflagReceiptParser(LocalReceiptParsing/→ symlink toSources/LocalReceiptParsing/) — Local receipt parsing libraryRevenueCatUI(RevenueCatUI/) — SwiftUI paywalls and customer center (depends onRevenueCat)
Key top-level directories: Sources/, RevenueCatUI/, Tests/, Examples/, Projects/, api/, fastlane/, Tuist/, Contributing/, scripts/. Explore the filesystem directly for current subdirectory layout.
Example apps
Examples/SampleCat/is the reference for app integration code (offerings, paywalls, checkpoints, Customer Center). The dashboard download writes over itsConstants.swift, so keep other values out of that file.- PaywallsTester, AutomatedSDKTestsApp, and
Tests/are test tools with debug seams. Do not copy them into app code.
Key Architectural Patterns
Core Purchases Module
- Singleton Pattern:
Purchases.sharedas the main entry point - Delegate Pattern:
PurchasesDelegatefor event callbacks - Manager Pattern:
IdentityManager,SubscriberAttributesManager,EventsManager - Backend/Cache Layer:
Backendfor networking,DeviceCachefor local storage - StoreKit 1 / StoreKit 2 dual implementation: The SDK maintains parallel code paths in
Sources/Purchasing/StoreKit1/andSources/Purchasing/StoreKit2/, with shared abstractions inSources/Purchasing/StoreKitAbstractions/. Bug fixes or behavior changes often need to be applied to both paths. Always check whether the other StoreKit implementation is affected.
RevenueCatUI Module
- SwiftUI-based: Modern declarative UI
- MVVM Pattern: ViewModels with SwiftUI views
- Main Components:
PaywallView,CustomerCenterView
Thread Safety
The SDK is heavily concurrent — StoreKit callbacks, delegate calls, caching, and network responses can all arrive on different threads. See Contributing/ThreadSafety.md for the full guide. Key primitives:
Lock/Lock(.recursive)— low-level synchronization for critical sectionsAtomic<T>— thread-safe wrapper for mutable stateSynchronizedUserDefaults— thread-safeUserDefaultsaccess (used byDeviceCache)
When modifying internal state, use these primitives. Don't introduce bare property access to shared mutable state without synchronization.
Logging
When logging to the debug console, follow the pattern with the strings in RevenueCatUI/Data/Strings.swift
API Annotations
@_spi(Internal)— APIs that are public only to be accessible by other modules or hybrid SDKs, not intended for external developer use@available— platform availability annotations for StoreKit 2 and other iOS version-specific features
Testing
Tests use XCTest, Nimble, swift-snapshot-testing, and OHHTTPStubs. Test targets live under Tests/ — explore subdirectories directly for the current layout.
Before you add or change a test, read Contributing/TESTING.md.
Performance tests
- Add memory benchmarks in
Tests/PerformanceTests/. Keep setup outside the measurement interval. - Use fixed fixtures. Verify that each iteration completes the expected work.
- Set the tolerance in each test. Keep the measured baseline in
Baselines.json. - Verify that a known memory regression fails before you use a benchmark as evidence.
- Keep the Xcode version, simulator, and workload consistent with the baseline.
- Never record a new baseline automatically after a failure.
The performance-tests CI job runs on each PR branch push. It uses Xcode 27.0.0, an iPhone 17 simulator with iOS 27.0, and Release configuration. It does not need an API key.
The workflow fixture is the published revision 24 of [performance-benchmark-1] (wff675a07c6c8b4540) in project 6627e5bc. It contains seven screens and its UI configuration. The benchmark loads Tests/PerformanceTests/Fixtures/performance-benchmark-1-revision-24.json without duplicate workflows or screens.
Export an approved published revision with:
mafdet workflow content --project-id 6627e5bc --workflow-id wff675a07c6c8b4540 \
--output /tmp/performance-benchmark-1.json
Keep the export unchanged. Include the revision in its filename. Review the fixture and record a new baseline after an intentional workload change.
For a local comparison:
xcodebuild test -workspace RevenueCat-Tuist.xcworkspace -scheme PerformanceTests \
-destination 'platform=iOS Simulator,name=iPhone 17,OS=27.0' \
-parallel-testing-enabled NO ENABLE_TESTABILITY=YES CODE_SIGNING_ALLOWED=NO
To update the baseline on a PR, comment @RCGitBot please test record-performance. The job records the measurements and pushes only Baselines.json to the PR branch. It stops if the branch changed during the run. Review the baseline diff before you merge.
To record a candidate baseline, start a CircleCI pipeline with action=record_performance_baselines. Download the Baselines.json artifact. Review its measurements and workload hash before you update the checked-in file. The job does not commit the file.
For local record mode, prefix the command with TEST_RUNNER_RECORD_PERFORMANCE_BASELINES=1. Use the same test selection for record and comparison runs. CI runs the full PerformanceTests target. Disable record mode before you compare results.
Development Workflow
For environment setup, see Contributing/CONTRIBUTING.md. For code style, see Contributing/SwiftStyleGuide.swift.
Main Entry Points
Purchasesclass: Primary SDK entry point (Sources/Purchasing/Purchases/Purchases.swift)Purchases.configure(withAPIKey:): Configuration method- UI Components:
PaywallView,CustomerCenterViewin RevenueCatUI
Key Dependencies
- Swift: Primary language with async/await support
- StoreKit / StoreKit 2: Apple's in-app purchase framework
- SwiftUI: UI framework for RevenueCatUI
- Foundation: Core Apple framework
Build Configuration
Package Manager Support
- Swift Package Manager:
Package.swift(primary) - CocoaPods:
RevenueCat.podspec,RevenueCatUI.podspec - Carthage: Supported via XCFramework
Tuist Project Management
The project uses Tuist for managing the Xcode workspace. See Contributing/DEVELOPMENT.md for full Tuist commands, environment variables, and troubleshooting.
Tuist environment variables (prefix all with TUIST_ when passing to tuist generate):
| Variable | Purpose | Default |
|---|---|---|
TUIST_RC_REMOTE=true |
Use remote instead of local RevenueCat dependency | local |
TUIST_RC_XCODE_PROJECT=true |
Use Xcode project instead of Swift Package dependency | SPM |
TUIST_INCLUDE_TEST_DEPENDENCIES=false |
Skip test/dev dependencies (Nimble, OHHTTPStubs, etc.) to speed up tuist install |
true |
TUIST_INCLUDE_XCFRAMEWORK_INSTALLATION_TESTS=true |
Include XCFrameworkInstallationTests project | false |
TUIST_SK_CONFIG_PATH=/path/to/file.storekit |
Custom StoreKit config for PaywallsTester scheme | — |
TUIST_RC_API_KEY=appl_xxxxx |
RevenueCat API key written to Local.xcconfig at generation time |
— |
TUIST_LAUNCH_ARGUMENTS="-Flag1 -Flag2" |
Space-separated launch arguments injected into PaywallsTester scheme run action (enabled by default) | — |
TUIST_SWIFT_CONDITIONS="FLAG1 FLAG2" |
Space-separated Swift compilation conditions injected into all targets | — |
TUIST_PAYWALLS_TESTER_BUNDLE_ID=com.x.PaywallsTester |
Custom bundle identifier for the PaywallsTester target (e.g. testing against another RevenueCat project) | com.revenuecat.PaywallsTester |
Example combining multiple variables:
TUIST_RC_API_KEY=appl_xxxxx TUIST_SWIFT_CONDITIONS="MY_FEATURE_FLAG" tuist generate PaywallsTester
Target Specifications
- Minimum Deployment: iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, visionOS 1.0
- Swift: 5.9+
- Xcode: 15.0+
These minimums are enforced at compile time. Concretely:
- Don't use APIs unavailable on the minimum target without an
@availablecheck and a fallback path (e.g.,AttributedStringis iOS 15+, Swift concurrency back-deployment requires iOS 13+ but some features need iOS 15+). - StoreKit 2 is iOS 15+ — all SK2 code paths must be gated with
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)or equivalent. - Never assume the latest OS — CI tests run on multiple OS versions, and customers run the SDK on older devices.
Development Notes
For snapshot testing, sample applications, pre-commit hooks, and release process details, see the docs in Contributing/ (in particular CONTRIBUTING.md, DEVELOPMENT.md, and RELEASING.md).
Important Files
.version- Current SDK versionLocal.xcconfig- Local development configuration (not committed)CI.xcconfig- CI-specific configurationapi/*.swiftinterface- Public API surface trackingswiftlint-baseline.json- Pre-existing SwiftLint violations grandfathered by the linter (notably forno_new_public_enumsandno_new_public_inits)
Pull Requests
Size & Structure
- Prefer smaller, self-contained stacked PRs when possible. "Self-contained" means the PR compiles, passes tests, and is reviewable on its own. As a rule of thumb, aim to keep the diff under ~300 lines of hand-written source — excluding generated, serialized, or non-reviewed files (e.g. JSON, snapshots, lockfiles,
api/*.swiftinterface) and test or example app files. For larger tasks, plan the PR breakdown up front and stack interdependent changes instead of bundling everything into one large PR.
Description
- Use the repo's PR template (
.github/PULL_REQUEST_TEMPLATE.md), applying the rules below. - Keep PR descriptions concise and easy to read. The description answers one question: what does the reviewer need that the diff doesn't already show? Concretely:
- Motivation: 1–2 sentences. State why the change exists; don't recap the whole backstory.
- Don't restate the diff — no file-by-file or change-by-change enumeration.
- No "Commits" section.
- Testing: only mention what the diff doesn't show. Don't enumerate added test cases.
Labels
When creating a pull request, always add one of these labels to categorize the change. These labels determine automatic version bumps and changelog generation:
| Label | When to Use |
|---|---|
pr:feat |
New user-facing features or enhancements |
pr:fix |
Bug fixes |
pr:other |
Internal changes, refactors, CI, docs, or anything that shouldn't trigger a release |
Additional scope labels (add alongside the primary label above):
pr:RevenueCatUI— Changes specific to the RevenueCatUI module (paywalls, customer center)feat:PaywallsV2— Changes related to Paywalls V2 (requirespr:RevenueCatUIas well)feat:Customer Center— Changes related to Customer Center (requirespr:RevenueCatUIas well)
Code Review Guidelines
When reviewing a pull request:
- Check for linked specs — If the PR description contains a link to
https://github.com/RevenueCat/sdk-specs, fetch and read the spec before reviewing the code. The spec defines the expected behavior and requirements. - Verify the implementation matches the spec — Ensure the code implements what the spec describes, including edge cases and error handling.
- Check cross-platform consistency — If the spec applies to multiple platforms, verify the implementation follows patterns from other SDKs (especially purchases-android).
When the Task is Ambiguous
- Search for similar existing implementation in this repo first
- Check purchases-android and purchases-hybrid-common for patterns
- If there's a pattern, follow it exactly
- If not, propose options with tradeoffs and pick the safest default
Guardrails
- Don't invent APIs or file paths — verify they exist before referencing them
- Don't remove code you don't understand — ask for context first
- Don't make large refactors unless explicitly requested
- Keep diffs minimal — only touch what's necessary, preserve existing formatting
- Don't break the public API — if API tests fail, investigate why
- Fix root causes — don't add workarounds or suppress errors without understanding the underlying issue
- Verify changes build — run
swift buildor the relevant Fastlane lane before considering work done - Run SwiftLint before committing (
swiftlintorswiftlint --fix) - Follow the style guide in
Contributing/SwiftStyleGuide.swift - Check Android SDK when unsure about cross-platform implementation details — new features should follow existing patterns across SDKs
- Never commit Claude-related files — do not stage or commit
.claude/directory,settings.local.json, or any AI tool configuration files - Never commit API keys or secrets — do not stage or commit API keys, tokens, credentials, or any sensitive data
- Remove dead code in your PR — delete unused parameters, unreachable branches, and leftover scaffolding before requesting review
- Don't widen access control for tests — don't add
internal/publicaccessors solely for test access; inject dependencies via protocols or test at the appropriate layer - Extract shared helpers — before duplicating logic, check for existing utilities (e.g.,
AvailabilityChecksfor OS-specific test skips); factor common code into helpers - Prefer structs for data holders — use structs instead of classes for value-like types unless Obj-C compatibility is required