Kotlin Multiplatform (KMP) has revolutionized how developers approach cross-platform development, allowing code to be shared across Android, iOS, and other platforms while maintaining native performance and capabilities.
But when building a multiplatform SDK, one of the most challenging decisions is whether to reimplement all the platform-specific logic from scratch, or wrap existing native SDKs?
RevenueCat’s Purchases KMP SDK chose the wrapping approach. The engineering decisions behind its implementation come from a desire to maintain clean, type-safe interfaces while delegating to battle-tested native code. The result is a wrapper system that avoids data duplication, maintains state consistency, and provides seamless bidirectional conversion between KMP and native types.
In the following deep dive, we’ll explore the delegation pattern used by RevenueCat, and how it gracefully wraps native Android and iOS SDKs, the engineering decisions that make bidirectional conversion effortless, and the subtle optimizations that keep the wrapper overhead minimal.
Understanding the core problem: Why wrap instead of reimplement
When building a multiplatform SDK, you face a fundamental architectural choice between sharing code and preserving platform-specific behavior. In our case at RevenueCat, we needed to integrate with the App Store on iOS and Google Play on Android, both of which have complex, platform-specific APIs.
So there’s two options:
Option 1. Pure KMP reimplementation: Reimplement all the business logic in common code and use expect/actual only for the thinnest possible platform layer. This gives you maximum control but requires duplicating complex logic that already exists in well-tested native SDKs.
Option 2. Wrap existing native SDKs: Leverage existing, battle-tested native implementations and create a thin KMP wrapper that provides a unified interface. This reduces implementation complexity but introduces the challenge of maintaining a clean abstraction layer.
RevenueCat chose the latter, wrapping our existing Android and iOS SDKs (which themselves wrap the Android Billing Client and iOS StoreKit). This decision made sense for several reasons:
The native SDKs have years of production-hardening and edge case handling. Billing and subscription logic involves complex state machines, network retry logic, receipt validation, and platform-specific quirks that would be expensive to reimplement.
The wrapper approach allows us to maintain a single source of truth for business logic in the native SDKs, while the KMP layer focuses purely on providing a unified API surface.
However, this introduces a new challenge: how do you design a wrapper that doesn’t create data synchronization problems, minimize performance overhead, and maintain type safety across the boundary?
The expect/actual foundation
If you have tried any Kotlin Multiplatform architecture, you’ll see it starts with Kotlin’s expect/actual mechanism. The common code declares what the API should look like, while platform-specific code provides the actual implementation.
The common interface looks like below in the RevenueCats KMP SDK:
1public expect class Purchases {
2    public companion object {
3        public val sharedInstance: Purchases
4        public var logLevel: LogLevel
5        public fun configure(configuration: PurchasesConfiguration): Purchases
6        public fun canMakePayments(features: List<BillingFeature>, callback: (Boolean) -> Unit)
7    }
8
9    public fun getOfferings(onError: (error: PurchasesError) -> Unit, onSuccess: (Offerings) -> Unit)
10    public fun purchase(storeProduct: StoreProduct, onError: ..., onSuccess: ...)
11    // ... many more functions
12}
13
This is a pure interface declaration, no implementation, no platform specifics. Developers using the SDK see only this clean API. The platform differences are completely hidden.
The wrapper delegation pattern
The actual implementations reveal the clever delegation strategy. Let’s examine the Android implementation:
1import com.revenuecat.purchases.Purchases as AndroidPurchases
2
3public actual class Purchases private constructor(
4    private val androidPurchases: AndroidPurchases
5) {
6    public actual companion object {
7        private var _sharedInstance: Purchases? = null
8
9        public actual fun configure(configuration: PurchasesConfiguration): Purchases {
10            with(configuration) {
11                commonConfigure(
12                    context = AndroidProvider.requireApplication(),
13                    apiKey = apiKey,
14                    appUserID = appUserId,
15                    purchasesAreCompletedBy = purchasesAreCompletedBy.toHybridString(),
16                    platformInfo = PlatformInfo(
17                        flavor = BuildKonfig.platformFlavor,
18                        version = frameworkVersion,
19                    ),
20                    store = (store ?: Store.PLAY_STORE).toAndroidStore(),
21                    dangerousSettings = dangerousSettings.toAndroidDangerousSettings(),
22                    shouldShowInAppMessagesAutomatically = showInAppMessagesAutomatically,
23                    verificationMode = verificationMode.name,
24                    pendingTransactionsForPrepaidPlansEnabled = pendingTransactionsForPrepaidPlansEnabled
25                )
26            }
27
28            return Purchases(AndroidPurchases.sharedInstance).also { _sharedInstance = it }
29        }
30    }
31}
32
The critical detail: private val androidPurchases: AndroidPurchases. The KMP Purchases class doesn’t duplicate the native implementation, it wraps it. Every KMP method delegates to the wrapped native object.
The iOS implementation follows the same pattern:
1import cocoapods.PurchasesHybridCommon.RCPurchases as IosPurchases
2
3public actual class Purchases private constructor(
4    private val iosPurchases: IosPurchases
5) {
6    public actual companion object {
7        private var _sharedInstance: Purchases? = null
8
9        public actual fun configure(configuration: PurchasesConfiguration): Purchases {
10            with(configuration) {
11                configureWithAPIKey(
12                    apiKey = apiKey,
13                    appUserID = appUserId,
14                    observerMode = purchasesAreCompletedBy.toHybridString() != "REVENUE_CAT",
15                    userDefaultsSuiteName = userDefaults,
16                    platformFlavor = BuildKonfig.platformFlavor,
17                    platformFlavorVersion = frameworkVersion,
18                    usesStoreKit2IfAvailable = storeKitVersion.usesStoreKit2IfAvailable,
19                    // ... more configuration
20                )
21            }
22
23            return Purchases(IosPurchases.sharedInstance()).also { _sharedInstance = it }
24        }
25    }
26}
27
Notice the symmetry: both platforms wrap their native singleton (AndroidPurchases.sharedInstance and IosPurchases.sharedInstance()), but the KMP code presents a unified interface to the consumer.
This pattern has a profound implication: there is no data duplication. The KMP wrapper doesn’t copy data from the native object, it holds a reference to it. This eliminates an entire class of bugs related to state synchronization.
The mapping layer: bridging KMP and native types
The delegation pattern works well for the main SDK class, but what about data types? When you call getOfferings(), the native SDK returns platform-specific types (com.revenuecat.purchases.Offerings on Android, RCOfferings on iOS). How do you convert these to a common KMP type?
This is where the mapping layer shines. RevenueCat implements a wrapper pattern for data types that maintains the same delegation principle. Let  ’s examine StoreProduct, which represents a purchasable item.
The common interface
First, the common code defines an interface:
1public interface StoreProduct {
2    public val id: String
3    public val type: ProductType
4    public val category: ProductCategory?
5    public val price: Price
6    public val title: String
7    public val localizedDescription: String?
8    public val period: Period?
9
10    // Android-specific properties (null on iOS)
11    public val subscriptionOptions: SubscriptionOptions?
12    public val defaultOption: SubscriptionOption?
13
14    // iOS-specific properties (empty/null on Android)
15    public val discounts: List<StoreProductDiscount>
16    public val introductoryDiscount: StoreProductDiscount?
17
18    public val purchasingData: PurchasingData
19    public val presentedOfferingContext: PresentedOfferingContext?
20}
21
This is a unified interface that exposes both Android and iOS concepts. Some properties are platform-specific (marked in comments), but the interface itself is common. This design allows platform-aware code while maintaining a single type definition.
The Android wrapper implementation
Now look at how the Android platform implements this:
1import com.revenuecat.purchases.models.StoreProduct as NativeAndroidStoreProduct
2
3public fun StoreProduct.toAndroidStoreProduct(): NativeAndroidStoreProduct =
4    (this as AndroidStoreProduct).wrapped
5
6public fun NativeAndroidStoreProduct.toStoreProduct(): StoreProduct =
7    AndroidStoreProduct(this)
8
9private class AndroidStoreProduct(
10    val wrapped: NativeAndroidStoreProduct,
11): StoreProduct {
12    override val id: String = wrapped.id
13    override val type: ProductType = wrapped.type.toProductType()
14    override val category: ProductCategory? = type.toProductCategoryOrNull()
15    override val price: Price = wrapped.price.toPrice()
16    override val title: String = wrapped.title
17    override val localizedDescription: String = wrapped.description
18    override val period: Period? = wrapped.period?.toPeriod()
19    override val subscriptionOptions: SubscriptionOptions? =
20        wrapped.subscriptionOptions?.toSubscriptionOptions()
21    override val defaultOption: SubscriptionOption? =
22        wrapped.defaultOption?.toSubscriptionOption()
23    override val discounts: List<StoreProductDiscount> = emptyList()
24    override val introductoryDiscount: StoreProductDiscount? = null
25    override val purchasingData: PurchasingData = AndroidPurchasingData(wrapped.purchasingData)
26    override val presentedOfferingContext: PresentedOfferingContext? =
27        wrapped.presentedOfferingContext?.toPresentedOfferingContext()
28}
29
Several remarkable patterns emerge here:
- The wrapper class is private: Consumers never see 
AndroidStoreProduct. They only interact with theStoreProductinterface. This encapsulation is critical for maintaining API flexibility. - The wrapped native object is stored:
val wrapped: NativeAndroidStoreProductholds a reference to the actual Android SDK object. Every property access delegates to this wrapped instance. - Properties are evaluated lazily by delegation: When you access 
storeProduct.id, it readswrapped.idat that moment. There’s no caching or copying, the value comes directly from the source of truth. - Platform-specific properties return appropriate defaults: The Android implementation returns 
emptyList()fordiscounts(an iOS-only feature) andnullforintroductoryDiscount. This graceful degradation is key to maintaining a unified interface. 
The iOS wrapper implementation
The iOS side mirrors this structure:
1import cocoapods.PurchasesHybridCommon.RCStoreProduct as NativeIosStoreProduct
2
3public fun NativeIosStoreProduct.toStoreProduct(): StoreProduct =
4    IosStoreProduct(this)
5
6public fun StoreProduct.toIosStoreProduct(): NativeIosStoreProduct =
7    (this as IosStoreProduct).wrapped
8
9private class IosStoreProduct(val wrapped: NativeIosStoreProduct): StoreProduct {
10    override val id: String = wrapped.productIdentifier()
11    override val type: ProductType = wrapped.productType().toProductType()
12    override val category: ProductCategory = wrapped.productCategory().toProductCategory()
13    override val price: Price = wrapped.toPrice()
14    override val title: String = wrapped.localizedTitle()
15    override val localizedDescription: String = wrapped.localizedDescription()
16    override val period: Period? = wrapped.subscriptionPeriod()?.toPeriod()
17    override val subscriptionOptions: SubscriptionOptions? = null
18    override val defaultOption: SubscriptionOption? = null
19    override val discounts: List<StoreProductDiscount> = wrapped.discounts()
20        .map { it as IosStoreProductDiscount }
21        .map { it.toStoreProductDiscount() }
22    override val introductoryDiscount: StoreProductDiscount? =
23        wrapped.introductoryDiscount()?.toStoreProductDiscount()
24    override val purchasingData: PurchasingData = IosPurchasingData(wrapped)
25    override val presentedOfferingContext: PresentedOfferingContext? = null
26}
27
The symmetry looks good. Both platforms follow the exact same pattern: wrap the native object, delegate property access, return platform-appropriate values for unsupported features.
Notice the difference in API style: Android uses property syntax (wrapped.id), while iOS uses function calls (wrapped.productIdentifier()). This reflects the underlying platform conventions, Kotlin properties on Android, Objective-C methods via Cocoapods on iOS. The wrapper abstracts these differences into a uniform interface.
The bidirectional conversion strategy
The genius of RevenueCat’s mapping layer lies in its bidirectional conversion functions. Look at the conversion functions again:
1// Android
2public fun StoreProduct.toAndroidStoreProduct(): NativeAndroidStoreProduct =
3    (this as AndroidStoreProduct).wrapped
4
5public fun NativeAndroidStoreProduct.toStoreProduct(): StoreProduct =
6    AndroidStoreProduct(this)
When you convert from KMP to native (toAndroidStoreProduct()), it doesn’t create a new object or copy data. It simply unwraps the reference to the native object that was already there. This is a cast followed by a property access, effectively zero cost at runtime.
When you convert from native to KMP (toStoreProduct()), it wraps the native object in the private wrapper class. This allocates one small wrapper object, but the heavy data (product details, pricing information, etc.) remains in the native object.
This bidirectional conversion enables a powerful programming model:
1// SDK receives native Android product from the billing library
2val nativeProduct: NativeAndroidStoreProduct = getFromAndroidBillingClient()
3
4// Wrap it for KMP consumers
5val kmpProduct: StoreProduct = nativeProduct.toStoreProduct()
6
7// Later, when calling a native API, unwrap it
8val purchaseParams = PurchaseParams.Builder()
9    .setProduct(kmpProduct.toAndroidStoreProduct())
10    .build()
The native object flows through the KMP layer without being copied. This pattern appears throughout the SDK:
1public actual fun purchase(
2    storeProduct: StoreProduct,
3    onError: (error: PurchasesError, userCancelled: Boolean) -> Unit,
4    onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit,
5) {
6    androidPurchases.purchaseWith(
7        PurchaseParams.Builder(
8            activity = AndroidProvider.requireActivity(),
9            purchasingData = storeProduct.toAndroidStoreProduct().purchasingData
10        ).build()
11    ) { purchase, customerInfo ->
12        onSuccess(
13            purchase.toStoreTransaction(),
14            customerInfo.toCustomerInfo()
15        )
16    }
17}
18
The storeProduct comes in as a KMP type, gets unwrapped to call the native API (toAndroidStoreProduct()), and the results get wrapped back into KMP types (toStoreTransaction(), toCustomerInfo()). The wrapper layer acts as a zero-copy bridge.
Handling platform differences gracefully
One of the most remarkable aspects of RevenueCat’s wrapper design is how it handles unavoidable platform differences. The App Store and Google Play have fundamentally different subscription models:
- iOS subscriptions use subscription groups with promotional offers, introductory pricing, and win-back offers
 - Android subscriptions use base plans with multiple subscription options, each containing pricing phases
 
Rather than forcing one model onto the other, RevenueCat exposes both in the common interface through nullable properties:
1public interface StoreProduct {
2    // Android-only
3    public val subscriptionOptions: SubscriptionOptions?
4    public val defaultOption: SubscriptionOption?
5
6    // iOS-only
7    public val discounts: List<StoreProductDiscount>
8    public val introductoryDiscount: StoreProductDiscount?
9}
10
This design allows platform-specific code to access platform features while common code works with the intersection:
1// Platform-specific code can check for Android features
2if (product.subscriptionOptions != null) {
3    // We're on Android, can access subscription options
4    val monthlyOption = product.subscriptionOptions.basePlan.monthlyOption
5}
6
7// Common code works with guaranteed properties
8val productPrice = product.price
9val productId = product.id
For method-level platform differences, RevenueCat uses different strategies. Some methods throw helpful errors:
1public actual fun getPromotionalOffer(
2    product: StoreProduct,
3    discount: StoreProductDiscount,
4    onError: (error: PurchasesError) -> Unit,
5    onSuccess: (offer: PromotionalOffer) -> Unit,
6): Unit = error(
7    "Getting promotional offers is not possible on Android. " +
8    "Did you mean StoreProduct.subscriptionOptions?"
9)
10
Others gracefully degrade:
1public actual fun checkTrialOrIntroPriceEligibility(
2    products: List<StoreProduct>,
3    callback: (Map<StoreProduct, IntroEligibilityStatus>) -> Unit,
4) {
5    logHandler.v(
6        tag = "Purchases",
7        msg = "Checking trial or introductory price eligibility is only available on iOS"
8    )
9    callback(products.associateWith { IntroEligibilityStatus.UNKNOWN })
10}
11
This design communicates platform capabilities clearly. A runtime error catches platform-mismatched code during development, while graceful degradation provides sensible behavior when features genuinely aren’t available.
The property delegation optimization
RevenueCat takes the delegation pattern even further with Kotlin’s property delegation syntax:
1public actual val isConfigured: Boolean by AndroidPurchases.Companion::isConfigured
This delegates the isConfigured property directly to the native SDK’s property using a method reference. It’s the ultimate zero-overhead wrapper, no custom getter, no function call overhead, just pure delegation to the underlying field.
You see this pattern used for simple properties that don’t need type conversion:
1public actual var simulatesAskToBuyInSandbox: Boolean
2    get() = IosPurchases.simulatesAskToBuyInSandbox()
3    set(value) = IosPurchases.setSimulatesAskToBuyInSandbox(value)
The getter directly calls the native getter. The setter directly calls the native setter. The wrapper is transparent.
Real-world implications and performance characteristics
This wrapper architecture has specific performance characteristics worth understanding:
- Allocation overhead is minimal: Each wrapped object allocates one small wrapper instance, but the actual data remains in the native object. For a 
StoreProduct, you allocate a tinyAndroidStoreProductorIosStoreProductwrapper, while the full product details live in the native SDK’s memory. - No synchronization overhead: Because the wrapper doesn’t copy data, there’s no synchronization to maintain. The wrapper always reflects the current state of the native object.
 - Method call overhead is single-dispatch: Every method call goes through one level of delegation: KMP wrapper → native implementation. Modern JVMs and iOS runtimes inline this effectively.
 - Type conversion happens on-demand: When you access product.price, the conversion from native Price to KMP Price happens at that moment. If you never access certain properties, they’re never converted.
 - Bidirectional conversion is zero-copy: Converting KMP → native is a type cast and property access. Converting native → KMP allocates only the wrapper object.
 
The trade-off is that you must ensure all conversions are correct. A bug in the mapping layer would propagate through the entire SDK. This is why RevenueCat’s mapping functions are tested extensively and why keeping them simple (pure delegation) reduces bug surface area.
Wrapping up
We’ve explored how RevenueCat’s KMP SDK uses a great wrapper delegation pattern to bridge native Android and iOS implementations with a unified multiplatform API. By now, you should have a clear sense of how to design a shared architecture that feels both native and maintainable across platforms.
The key idea isn’t just the expect/actual pattern or wrapper delegation, it’s how these techniques work together to balance code reuse with platform fidelity. When done right, you get the best of both worlds: clean abstractions, zero-copy efficiency, and a scalable foundation for future multiplatform work.
This architecture demonstrates that wrapping native SDKs doesn’t mean compromising on performance or API quality. By carefully designing the wrapper to delegate rather than duplicate, RevenueCat achieves the best of both worlds: a unified API surface powered by battle-tested native implementations.Whether you’re wrapping existing native libraries in KMP, designing data transfer objects across platform boundaries, or building SDK abstractions, these patterns provide a foundation for clean, performant multiplatform code. The key insight is simple but clear: don’t copy, delegate.
If you’d like to learn how we decided to adopt the wrapper pattern, check out How We Built the RevenueCat SDK for Kotlin Multiplatform.

