---
title: "KMP Subscription Architecture: One Engine, Two Platforms"
published: true
description: "Build a shared KMP subscription engine with expect/actual interfaces, server-side receipt validation, and a single entitlement state machine for iOS and Android."
tags: kotlin, mobile, architecture, android
canonical_url: https://mvpfactory.co/blog/kmp-subscription-architecture
---
## What We Are Building
By the end of this tutorial you will have a shared Kotlin Multiplatform subscription engine that abstracts RevenueCat, StoreKit 2, and Google Play Billing behind a single `expect`/`actual` interface, runs one entitlement state machine for both platforms, and handles grace periods, billing retries, and cache invalidation from a single source of truth.
No more duplicating billing edge-case logic across Swift and Kotlin.
## Prerequisites
- Kotlin Multiplatform project with `commonMain`, `iosMain`, and `androidMain` source sets
- RevenueCat SDK configured on both platforms (or your preferred billing provider)
- A backend endpoint for server-side receipt validation
- Familiarity with Kotlin coroutines and sealed classes
---
## Step 1 — Define the Purchase Interface in commonMain
Let me show you a pattern I use in every project. Define the abstraction first, before you touch any platform SDK.
kotlin
// commonMain
interface PurchaseProvider {
suspend fun purchase(productId: String): PurchaseResult
suspend fun restorePurchases(): List
suspend fun getOfferings(): List
}
expect fun createPurchaseProvider(): PurchaseProvider
Each platform delivers its own `actual` implementation:
kotlin
// iosMain
actual fun createPurchaseProvider(): PurchaseProvider = RevenueCatIOSProvider()
// androidMain
actual fun createPurchaseProvider(): PurchaseProvider = RevenueCatAndroidProvider()
The `expect`/`actual` boundary keeps your shared business logic decoupled from any specific vendor — including RevenueCat itself.
---
## Step 2 — Normalize the Entitlement Contract
iOS validates via the App Store Server API with JWT-signed JWS transactions. Android uses Google Real-Time Developer Notifications via Pub/Sub. Two completely different mechanisms — but your shared module should not care about either.
Your backend exposes a single `/validate-receipt` endpoint and returns a platform-agnostic payload:
kotlin
// commonMain
data class EntitlementState(
val isActive: Boolean,
val expiresAt: Instant,
val inGracePeriod: Boolean,
val billingRetryActive: Boolean,
val billingRetryUntil: Instant?
)
Here is what your backend absorbs so your KMP module does not have to:
| Concern | iOS | Android |
|---|---|---|
| Validation method | JWS transaction (JWT) | Purchase token via REST |
| Real-time events | App Store Server Notifications | RTDN via Cloud Pub/Sub |
| Grace period signal | `expirationIntent` field | `paymentState = 0` |
| Billing retry window | Up to 60 days | Up to 30 days |
---
## Step 3 — Build the State Machine in Shared Code
This is where most teams lose real money. Grace periods and billing retries gate feature access — model them as first-class states, not UI edge cases.
kotlin
sealed class SubscriptionState {
object Active : SubscriptionState()
data class GracePeriod(val expiresAt: Instant) : SubscriptionState()
data class BillingRetry(val retryUntil: Instant) : SubscriptionState()
object Expired : SubscriptionState()
object NeverSubscribed : SubscriptionState()
}
class SubscriptionStateMachine(
private val provider: PurchaseProvider,
private val cache: EntitlementCache
) {
fun resolve(entitlement: EntitlementState): SubscriptionState = when {
entitlement.isActive -> SubscriptionState.Active
entitlement.inGracePeriod -> SubscriptionState.GracePeriod(entitlement.expiresAt)
entitlement.billingRetryActive && entitlement.billingRetryUntil != null ->
SubscriptionState.BillingRetry(entitlement.billingRetryUntil)
entitlement.expiresAt < Clock.System.now() -> SubscriptionState.Expired
else -> SubscriptionState.NeverSubscribed
}
suspend fun refresh(): EntitlementState {
val fresh = provider.restorePurchases()
val state = resolveFromResults(fresh)
cache.write(state)
return state
}
}
This runs entirely in `commonMain`. Your SwiftUI views and Compose screens observe the same `SubscriptionState` flow — no platform-specific branching required.
---
## Step 4 — Cache Without Race Conditions
Cache on device, validate server-side asynchronously, and never block the UI on cold start. Here is the minimal setup to get this working: a two-layer strategy using local `EncryptedSharedPreferences`/Keychain for instant reads, with a background refresh triggered on app foreground. Invalidate eagerly on any purchase event.
Five minutes is a reasonable TTL default — short enough that a cancelled subscriber won't keep seeing premium content, long enough to avoid hammering your validation endpoint on typical mobile sessions. Make it configurable so you can tighten it for high-value paywalls without shipping a release.
---
## Gotchas
**Starting with the platform SDK instead of the interface.** The docs do not mention this, but the abstraction boundary also gives you a clean seam for unit testing your entire subscription lifecycle without a physical device or a live App Store sandbox account. Define `PurchaseProvider` in `commonMain` first, always.
**Treating grace periods as UI state.** A subscriber in a grace period still has active access — but your state machine needs to know when to show a recovery paywall. If `inGracePeriod` and `billingRetryActive` are not first-class states, you will ship a bug on one platform that was already fixed on the other six months earlier.
**Putting receipt validation logic in the client.** JWT handling for the App Store Server API and RTDN for Google Play belong on your backend. Return a normalized `EntitlementState` with a dedicated `billingRetryUntil` field and let shared code consume it cleanly.
**Hardcoding the cache TTL.** Five minutes works for most paywalls. For high-value content you will want to tighten this without a release cycle. Make it configurable from day one.
---
## Conclusion
Six subscription concerns across two platforms used to mean twelve code paths to maintain. With KMP's `expect`/`actual` pattern, a normalized backend contract, and a state machine living entirely in `commonMain`, you collapse that to one engine with thin platform adapters. The billing edge case you fix today gets fixed on both platforms simultaneously.
**Resources:**
- [Kotlin Multiplatform — expect/actual](https://kotlinlang.org/docs/multiplatform-expect-actual.html)
- [App Store Server API](https://developer.apple.com/documentation/appstoreserverapi)
- [Google Play Developer API — Subscriptions](https://developer.android.com/google/play/billing/getting-ready)
- [RevenueCat Kotlin Multiplatform](https://www.revenuecat.com/docs/getting-started/installation/kotlin-multiplatform)
Top comments (0)