From Monolith iOS to Kotlin Multiplatform: sugarlite's Incremental Migration (Part 2)

Part 1 documented how sugarlite sunk its data layer from the Supabase Swift SDK down into the KMP shared/ module. But we left one thing deliberately untouched — ViewModels stayed on iOS using @Observable. We said we’d “keep them put” for now.

Well, we broke that promise.

As Android development accelerated, 21 of our 23 ViewModels needed to work on both platforms. Maintaining one set of Swift ViewModels for iOS and another set of Kotlin ViewModels for Android was clearly unsustainable — why write the same business logic (fetch today’s blood sugar, build a timeline, calculate glycemic response) twice?

This post picks up where Part 1 left off: how we migrated ViewModels to KMP, and everything that came with it — local storage unification, platform abstractions, subscription management, and Swift-Kotlin interop at scale.

Part 1’s migration principle #4 said: “ViewModels stay put — keep @Observable to preserve the SwiftUI reactive experience.” That was the right call at the time. Kotlin/Native compiles StateFlow into an Objective-C type — what exactly would the Swift side consume? You couldn’t ask the iOS team to hand-write a Combine bridge for every ViewModel.

The game-changer was SKIE (0.10.13). This Kotlin compiler plugin from TouchLab automatically maps Kotlin types to idiomatic Swift:

  • suspend functions → Swift async throws
  • StateFlow<T> / Flow<T> → Swift AsyncSequence (backed by generated SkieSwiftAsyncSequence)
  • Enums and sealed classes → Swift enum

With SKIE, consuming a KMP ViewModel from SwiftUI feels almost as natural as a native @Observable.

After migrating 23 ViewModels, a unified pattern emerged:

// 1. Single UiState data class that holds all UI state
data class HomeUiState(
    val selectedDate: Instant = Clock.System.now(),
    val bloodSugarRecords: List<BloodSugarRecord> = emptyList(),
    val timelineEvents: List<TimelineEvent> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
)

// 2. ViewModel extends KMP ViewModel
class HomeViewModel : ViewModel() {

    // 3. Private mutable backing flow + public read-only flow
    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    // 4. viewModelScope for structured concurrency
    fun loadData() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            try {
                val uid = getCurrentUserId()
                val records = getDailyBloodSugarRecords(dateMillis, uid)
                _uiState.update {
                    it.copy(isLoading = false, bloodSugarRecords = records.map { r -> r.toModel() })
                }
            } catch (e: Exception) {
                _uiState.update { it.copy(isLoading = false, errorMessage = e.message) }
            }
        }
    }

    // 5. Always mutate state with update { it.copy(...) }
    fun clearError() {
        _uiState.update { it.copy(errorMessage = null) }
    }
}

Key design decisions:

  1. One ViewModel, one uiState: StateFlow. No multiple independent flows — that would force the iOS side to manage multiple subscriptions.
  2. State is a single data class. On Android, collectAsStateWithLifecycle() destructures it directly. On iOS, SKIE converts it to an AsyncSequence — one for await gets all state in a single receive.
  3. All mutations use _uiState.update { it.copy(...) }. Never _uiState.value = ...update is atomic and avoids race conditions.
  4. Dependencies are top-level use-case functions, not Koin. ViewModel instantiation is just HomeViewModel() — zero DI overhead.

Here’s how OnboardingViewModel bridges to SwiftUI:

// OnboardingStateHolder.swift
import Shared

@MainActor
final class OnboardingStateHolder: ObservableObject {
    @Published var state: HomeUiState = .init()
    private let viewModel = Shared.OnboardingViewModel()
    private var collectionTask: Task<Void, Never>?

    func startObserving() {
        collectionTask = Task { [weak self] in
            // SKIE converts StateFlow to AsyncSequence
            for await state in self?.viewModel.uiState ?? AsyncStream<HomeUiState>.empty {
                guard let self else { return }
                self.state = state // drives SwiftUI updates
            }
        }

        viewModel.loadData()
    }

    deinit {
        collectionTask?.cancel()  // clean up subscription
    }
}

Three steps: for await state in viewModel.uiState → assign to @Published → SwiftUI re-renders. No third-party bridging libraries.

We also wrote a small Swift extension for single-value reads:

extension SkieSwiftFlowProtocol {
    func first() async throws -> Element {
        for try await element in self {
            return element
        }
        throw NSError(domain: "SkieSwiftFlow", code: -1,
            userInfo: [NSLocalizedDescriptionKey: "Flow completed without emitting a value"])
    }
}

With this, a one-shot check becomes let state = try await viewModel.uiState.first() — no loop needed.

On Android, it’s even simpler with Compose:

@Composable
fun HomeScreen() {
    val viewModel = remember { HomeViewModel() }
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    HomeContent(
        records = uiState.bloodSugarRecords,
        isLoading = uiState.isLoading,
        onRefresh = { viewModel.loadData() }
    )
}

One line: collectAsStateWithLifecycle(). Done.

Before (iOS @Observable ViewModel, ~300 lines of Swift):

@Observable
final class HomeViewModel {
    var bloodSugarRecords: [BloodSugarRecord] = []
    var isLoading = false
    var errorMessage: String?

    func loadData() async {
        isLoading = true
        do {
            let dtos = try await repository.fetchDailyRecords(date: selectedDate)
            bloodSugarRecords = dtos.map { BloodSugarRecord(from: $0) }
            isLoading = false
        } catch {
            errorMessage = error.localizedDescription
            isLoading = false
        }
    }
}

After (KMP ViewModel, ~370 lines of Kotlin, shared by Android + iOS):

The KMP ViewModel is as shown above. The iOS side shrank from 300+ lines of business logic to a ~50-line StateHolder bridge with @Published property mapping. Android went from zero to fully functional, consuming the same uiState flow.

Key wins:

  • Business logic written once. Blood sugar deduplication, timeline aggregation, and unit conversion no longer need Swift + Kotlin dual maintenance.
  • The iOS StateHolder is razor-thin — pure data forwarding with zero business logic. It acts like a “remote control” for native SwiftUI views.
  • New features go into KMP first, available on both platforms immediately.

Part 1 solved remote storage by sinking the data layer. But local persistence was still split: iOS used SwiftData, Android used Room. These frameworks have entirely different schemas, query syntax, and migration mechanisms. Maintaining two local storage stacks was almost as painful as maintaining two network layers.

SwiftData is Apple-only. Room KMP reached production maturity with Room 2.8’s official KMP support in late 2024. Our decision was clear: unify local storage in shared/ as well.

@Database(
    entities = [
        BloodSugarRecordEntity::class,
        FoodRecordEntity::class,
        ExerciseRecordEntity::class,
        UserProfileEntity::class,
        // ... 14 entities total
    ],
    version = 12
)
@TypeConverters(InstantConverter::class)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun bloodSugarDao(): BloodSugarDao
    abstract fun foodRecordDao(): FoodRecordDao
    abstract fun exerciseRecordDao(): ExerciseRecordDao
    // ... 13 DAOs total
}

Every entity has built-in offline-sync fields:

@Entity(tableName = "blood_sugar_records")
data class BloodSugarRecordEntity(
    @PrimaryKey val id: String,
    val uid: String,
    val value: Double,
    val isDirty: Boolean = false,       // has unsynced changes?
    val deleted: Boolean = false,        // soft delete marker
    val syncVersion: Long = 0,           // optimistic lock version
    val sourceDeviceId: String = "",     // which device created this
    @ColumnInfo(name = "created_at") val createdAt: Long,
    // ...
)

The sync strategy is straightforward: on upload, query isDirty = 1 records and push to Supabase. On download, pull remote deltas by uid and merge locally with Upsert.

Room needs to know the physical database file path. On iOS it’s in the NSDocumentDirectory sandbox; on Android it’s in the app’s database directory. We abstracted this with an expect interface:

// commonMain — interface definition
interface DatabaseFactory {
    fun createBuilder(): RoomDatabase.Builder<AppDatabase>
}

fun getRoomDatabase(factory: DatabaseFactory): AppDatabase {
    return factory.createBuilder()
        .setDriver(BundledSQLiteDriver())
        .setQueryCoroutineContext(Dispatchers.IO)
        .addMigrations(MIGRATION_3_4, MIGRATION_4_5, /* ... 9 migrations total */)
        .fallbackToDestructiveMigration(true)
        .build()
}
// iosMain — NSFileManager for documents directory
class IOSDatabaseFactory : DatabaseFactory {
    override fun createBuilder(): RoomDatabase.Builder<AppDatabase> {
        val dbPath = documentDirectory() + "/sugarlite.db"
        return Room.databaseBuilder<AppDatabase>(name = dbPath)
    }

    @OptIn(ExperimentalForeignApi::class)
    private fun documentDirectory(): String {
        val url = NSFileManager.defaultManager.URLForDirectory(
            directory = NSDocumentDirectory,
            inDomain = NSUserDomainMask,
            appropriateForURL = null, create = false, error = null
        )
        return requireNotNull(url?.path)
    }
}
// androidMain — Context.getDatabasePath
class AndroidDatabaseFactory(private val context: Context) : DatabaseFactory {
    override fun createBuilder(): RoomDatabase.Builder<AppDatabase> {
        val dbFile = context.getDatabasePath("sugarlite.db")
        return Room.databaseBuilder<AppDatabase>(
            context = context.applicationContext,
            name = dbFile.absolutePath
        )
    }
}

Both platforms use the same database filename (sugarlite.db), with the DatabaseFactory implementation injected via Koin per platform.

Users who installed the app before Room KMP still had legacy SwiftData stores on their devices. We built a SwiftDataMigrationManager to handle this one-time migration:

// SwiftDataMigrationManager.swift
@MainActor
final class SwiftDataMigrationManager {
    func migrateIfNeeded(context: ModelContext) async {
        let dao = Shared.CloudSourceProviderKt.getMigrationMetadataDao()
        // Already migrated?
        if let meta = try? await dao.getByKey(key: "swift_data_imported"),
           meta.swiftDataImported { return }

        // Read old data from SwiftData → write to Room KMP
        await performMigration(context: context)
    }

    private func performMigration(context: ModelContext) async {
        let descriptor = FetchDescriptor<UserProfile>()
        let oldProfiles = try? context.fetch(descriptor)
        for profile in oldProfiles ?? [] {
            let dto = profile.toDto()
            try? await Shared.CloudSourceProviderKt.getUserProfileLocalSource().upsert(dto: dto)
        }
        // ... same for BloodSugarRecord, FoodRecord, ExerciseRecord

        // Mark migration complete
        try? await Shared.CloudSourceProviderKt.getMigrationMetadataDao()
            .upsert(MigrationMetadataEntity(swiftDataImported: true))
    }
}

The migration runs once at app launch, then never again. Legacy SwiftData model code stays in the project (read-only), but all new data goes through Room KMP.

The Room database evolved from version 3 to 12 across 9 incremental migrations. Here are some notable ones:

MigrationVersionsChange
MIGRATION_3_43→4Added sync fields (is_dirty, deleted, sync_version) to food_glycemic_responses
MIGRATION_5_65→6Removed food_name column. Since SQLite doesn’t support DROP COLUMN, used the table-rebuild pattern: new table → copy data → drop old → rename
MIGRATION_11_1211→12Removed 5 redundant cached columns from exercise_records (cached_exercise_name, etc.), replaced with real-time queries from exercise_reference

The table-rebuild pattern is a common Room migration technique:

val MIGRATION_5_6 = object : Migration(5, 6) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("""
            CREATE TABLE food_glycemic_responses_new (... new schema, without food_name)
        """)
        db.execSQL("INSERT INTO food_glycemic_responses_new SELECT ... FROM food_glycemic_responses")
        db.execSQL("DROP TABLE food_glycemic_responses")
        db.execSQL("ALTER TABLE food_glycemic_responses_new RENAME TO food_glycemic_responses")
        // Rebuild indices
        db.execSQL("CREATE INDEX index_food_glycemic_responses_uid ON food_glycemic_responses(uid)")
    }
}

During prototyping, an CREATE INDEX IF NOT EXISTS index_xyztree ... in a migration script would fail on certain SQLite builds. R*Tree indices can only be created locally — copying them across schemas requires creating a virtual table first, inserting rows one at a time, then rebuilding the associated indices. We ultimately dodged this by dropping the no-longer-needed index in migration v10→v11 rather than trying to port it over.

After migrating ViewModels and Room, over 85% of the code lives in shared/. What about the remaining platform-specific bits?

Kotlin’s expect/actual mechanism lets you declare interfaces in commonMain and provide platform implementations in androidMain/iosMain. sugarlite has exactly 5 expect/actual declarations, each intentionally lightweight:

// commonMain
expect object PlatformInfo {
    val platform: String       // "ios" / "android"
    val osVersion: String      // "17.5" / "14"
    val deviceModel: String    // "iPhone15,2" / "Pixel 8"
    val appVersion: String     // "1.2.3"
}
// iosMain
actual object PlatformInfo {
    actual val platform: String = "ios"
    actual val osVersion: String get() = UIDevice.currentDevice.systemVersion
    actual val deviceModel: String get() = UIDevice.currentDevice.model
    actual val appVersion: String get() =
        NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "unknown"
}
// androidMain
actual object PlatformInfo {
    actual val platform: String = "android"
    actual val osVersion: String get() = Build.VERSION.RELEASE
    actual val deviceModel: String get() = Build.MODEL
    actual var appVersion: String = "unknown"

    fun init(context: Context) {
        val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
        appVersion = pkgInfo.versionName ?: "unknown"
    }
}

Used for: app launch logging, attaching device context to feedback submissions, and the User-Agent header in Supabase requests.

// commonMain
expect fun getDeviceLanguageCode(): String

// iosMain
actual fun getDeviceLanguageCode(): String = NSLocale.currentLocale.languageCode ?: "en"

// androidMain
actual fun getDeviceLanguageCode(): String = java.util.Locale.getDefault().language

Used for: Supabase Edge Function Accept-Language headers and default language selection on first launch.

// commonMain
expect fun generateUUID(): String

// iosMain
actual fun generateUUID(): String = platform.Foundation.NSUUID().UUIDString()

// androidMain
actual fun generateUUID(): String = java.util.UUID.randomUUID().toString()

Used for: generating unique IDs for records created offline (before syncing to Supabase).

// commonMain
expect val platformModule: Module

This is the “heaviest” expect/actual — it wires platform-specific implementations into Koin:

// iosMain
actual val platformModule: Module = module {
    single<DatabaseFactory> { IOSDatabaseFactory() }
    single<HealthDataSyncRepository> { HealthDataSyncRepositoryIos() }
    single<SettingsRepository> { SettingsRepositoryIos() }
}
// androidMain
actual val platformModule: Module = module {
    single<DatabaseFactory> { AndroidDatabaseFactory(androidContext()) }
    single<HealthDataSyncRepository> { HealthDataSyncRepositoryAndroid(androidContext()) }
    single<SettingsRepository> { SettingsRepositoryAndroid(androidContext()) }
}
// KoinHelper.kt — iOS-side Koin bootstrap
fun doInitKoin() {
    initKermitForIos()
    startKoin {
        modules(sharedModule, platformModule)  // platformModule from expect/actual
    }
}

The SwiftUI entry point calls KoinHelperKt.doInitKoin() — that’s all the initialization iOS needs.

// commonMain — actual is generated by Room KSP
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase>

This one is generated at compile time — no handwritten actual needed.

5 declarations, fewer than 200 lines of actual code total. We follow two rules:

  1. Abstract data-source differences, not behavioral differences. iOS HealthKit and Android Health Connect may sound similar, but their APIs are fundamentally different shapes. Trying to paper over the gap with an expect interface would produce awkward signatures. HealthDataSyncRepositoryIos is currently a stub (all methods return empty) — it’ll be swapped for a real implementation when HealthKit integration is prioritized.
  2. Keep granularity small. expect fun getDeviceLanguageCode(): String is much easier to maintain than expect class LocaleManager. Each expect does exactly one thing; each platform implementation has zero extra dependencies and is trivial to test.

For Swift code to call into the KMP shared module, Kotlin/Native compiles it into an Objective-C-compatible framework. But the default Kotlin→ObjC mapping is rough: suspend functions become completion-handler callbacks, Flow is invisible, and StateFlow becomes Kotlinx_coroutines_coreStateFlow.

SKIE fixes all of this. Configuration is minimal:

// shared/build.gradle.kts
plugins {
    alias(libs.plugins.skie)  // 0.10.13, zero config needed
}

kotlin {
    iosTarget.binaries.framework {
        baseName = "Shared"
        isStatic = true
        export(libs.androidx.lifecycle.viewmodel)  // export for iOS visibility
    }
}

No custom skie {} block required. SKIE enables Feature_CoroutinesInterop by default, automatically converting:

  • suspend fun → Swift async throws
  • Flow<T> / StateFlow<T> → Swift AsyncSequence
  • All types get Swift-readable names

With SKIE handling the function signature bridging, we designed a centralized entry point — CloudSourceProvider.kt — that uses KoinComponent to pull instances from the DI container and exposes them as top-level functions to Swift:

// commonMain, exposed to Swift via SKIE
internal object KoinProvider : KoinComponent {
    fun provideBloodSugarLocalSource(): BloodSugarLocalSource = get()
    fun provideMembershipRepository(): MembershipRepository = get()
    // ...
}

// Ongoing subscriptions: return Flow → SKIE → Swift AsyncSequence
fun getBloodSugarRecordsByUid(uid: String): Flow<List<BloodSugarRecordDto>> =
    KoinProvider.provideBloodSugarLocalSource().getByUidFlow(uid.lowercase())

fun observeMembershipState(): Flow<MembershipState> =
    KoinProvider.provideMembershipRepository().membershipState

// Write operations: suspend → SKIE → Swift async throws
@Throws(HttpRequestException::class, RestException::class, IOException::class)
suspend fun saveBloodSugarRecord(dto: BloodSugarRecordDto): BloodSugarRecordDto =
    KoinProvider.provideBloodSugarRepository().save(dto)

// Single-record lookups: plain suspend
suspend fun getBloodSugarRecordById(id: String): BloodSugarRecordDto? =
    KoinProvider.provideBloodSugarLocalSource().getById(id)

A few lessons from production:

  • Ongoing subscriptions use Flow: getBloodSugarRecordsByUid() returns Flow — the iOS side uses for await to react to database changes in real time.
  • One-shot operations use suspend: saveBloodSugarRecord() — Swift side calls try await directly.
  • @Throws matters: Without it, Kotlin exceptions surface as generic NSError on the Swift side. With it, Swift can catch by specific exception type.
import Shared

// Ongoing subscription — SKIE converts Flow to AsyncSequence
let flow = Shared.CloudSourceProviderKt.getBloodSugarRecordsByUid(uid: userId)
for await records in flow {
    self.records = records
}

// One-shot write
try await Shared.CloudSourceProviderKt.saveBloodSugarRecord(dto: newRecord)

// Subscribe to membership state
let stateFlow = Shared.CloudSourceProviderKt.observeMembershipState()
for await state in stateFlow {
    self.membershipState = state
}

No callbacks, no delegates, no completion handlers. This is the value of SKIE — Kotlin code that feels almost as native in Swift as Swift code does.

A smooth calling path is one thing, but exception handling is an easy trap to fall into. Kotlin and Swift have fundamentally different error models — Kotlin has no checked exceptions (everything is thrown at runtime), while Swift uses throws + do/catch with explicit error typing. What happens when a Kotlin exception crosses the Kotlin/Native boundary into Swift?

Kotlin and Swift’s exception models are fundamentally different. Kotlin exceptions are all unchecked — you can’t see what a function might throw from its signature. Swift requires compile-time declaration via throws.

Let’s start with the wrong way. Suppose CloudSourceProvider has a suspend function without @Throws:

// ❌ No @Throws
suspend fun saveBloodSugarRecord(dto: BloodSugarRecordDto): BloodSugarRecordDto =
    KoinProvider.provideBloodSugarRepository().save(dto)

Many developers see throws in the compiled Swift signature and assume catch will handle it:

// The compiled Swift signature does show throws
func saveBloodSugarRecord(dto: BloodSugarRecordDto) async throws -> BloodSugarRecordDto

The reality is much more dangerous. Per the official KMP documentation, when a suspend function lacks @Throws:

  • CancellationException → propagates as NSError (this is the sole exception)
  • Any other exception (HttpRequestException, IOException, custom business exceptions…) → treated as unhandled, reaches Swift and causes program termination — i.e. a crash

The following code looks harmless, but if anything other than CancellationException is thrown, your app crashes outright:

do {
    let result = try await Shared.CloudSourceProviderKt.saveBloodSugarRecord(dto: record)
} catch {
    // ⚠️ This catch will NEVER see HttpRequestException!
    // The exception already crashed the program before reaching Swift
}

Regular (non-suspend) functions are even stricter: they propagate zero Kotlin exceptions at all. Any exception crossing the boundary is an instant crash.

So @Throws isn’t an “add better types to your errors” optimization — it’s a safety gate. Only the types (and their subclasses) listed in the @Throws annotation are safely converted to NSError. Exceptions not on the list still crash.

The @Throws annotation tells the Kotlin/Native compiler: “Only forward the types (and their subclasses) listed here as safe NSError. Everything else still crashes.” That’s why every write function in CloudSourceProvider carries precise exception declarations:

// ✅ With @Throws — only these exception types propagate safely
@Throws(
    HttpRequestException::class,      // HTTP timeout, connection failure
    RestException::class,             // Supabase REST error (e.g. constraint violation)
    UnknownRestException::class,      // Unclassified HTTP error
    HttpRequestTimeoutException::class, // Ktor client timeout
    IOException::class,               // Network I/O error
    CancellationException::class,     // Coroutine cancellation
)
suspend fun saveBloodSugarRecord(dto: BloodSugarRecordDto): BloodSugarRecordDto =
    KoinProvider.provideBloodSugarRepository().save(dto)

With @Throws in place, the Swift side can catch by type — and only these types will ever reach the catch block:

do {
    let result = try await Shared.CloudSourceProviderKt.saveBloodSugarRecord(dto: record)
} catch let error as Shared.HttpRequestException {
    // Network timeout — tell the user "saved locally, will sync later"
    toast("Network unavailable — saved offline")
    // Room KMP data is still there; next sync will push it up
} catch let error as Shared.RestException {
    // Supabase backend error — log silently, degrade
    AppLogger.error("Backend rejected: \(error)")
} catch {
    // ⚠️ If we ever reach here, it's an exception type NOT in the @Throws list
    // — which means the program likely already crashed
    // So make sure your @Throws list is complete
}

The key win: network timeouts don’t alarm the user; backend errors are logged silently. Two scenarios, two different UX paths.

A subtle but critical detail: CancellationException in Kotlin is not a normal exception — it’s the cancellation signal for structured concurrency. Any try/catch in Kotlin code that may run inside a coroutine must re-throw it:

suspend fun initializeSupabaseAuth() {
    try {
        val loaded = sharedSupabaseClient.auth.loadFromStorage()
        // ...
    } catch (e: IllegalStateException) {
        AppLogger.i("AuthInit") { "No saved session locally" }
    } catch (e: Exception) {
        if (e is CancellationException) throw e  // ← MUST re-throw!
        AppLogger.e("AuthInit", e) { "Failed to initialize Supabase Auth" }
    }
}

Swallowing CancellationException prevents the coroutine scope from cancelling properly, which can cause resource leaks — imagine a ViewModel that’s already been destroyed, but its coroutine is still running a Room query.

Not every function needs @Throws. Query functions (returning Flow or plain suspend) can sometimes skip it — but only if exceptions are caught on the Kotlin side first:

  1. Exceptions inside Flow propagate through the Flow itself — SKIE wraps them into AsyncSequence iteration, and the Swift side catches them in for try await. Crucially, a Flow exception terminates the stream but does not crash the program.
  2. One-shot suspend lookups (like getBloodSugarRecordById) are risky without @Throws. While “not found” → null doesn’t need exceptions, a Room database error (e.g. corruption) would crash the app. The safe pattern is to wrap in try/catch on the Kotlin side and convert to null:
// Exceptions caught on the Kotlin side — safe to omit @Throws
suspend fun getBloodSugarRecordById(id: String): BloodSugarRecordDto? =
    try {
        KoinProvider.provideBloodSugarLocalSource().getById(id)
    } catch (e: Exception) {
        AppLogger.e("Lookup failed", e)
        null
    }

Only omit @Throws when you’re certain all exceptions are handled before crossing the language boundary.

  1. Every function exposed across the language boundary needs @Throws with a complete type list. This isn’t a code style preference — it’s “crash vs. not crash.” A single unlisted exception type is a time bomb.
  2. Be precise AND complete with your @Throws types. Don’t write @Throws(Exception::class) — that’s just as unhelpful as no @Throws at all. Separate HttpRequestException, RestException, and IOException. At the same time, make sure every exception type that could actually be thrown is in the list.
  3. Never swallow CancellationException. Add if (e is CancellationException) throw e in every generic Kotlin-side catch block. Even when it’s declared in the @Throws list, swallowing it still prevents proper coroutine cancellation.
  4. If you want to skip @Throws on a query function, absorb all exceptions on the Kotlin side first. Convert them to null, a default value, or a sealed result type — make sure nothing propagates across the boundary unhandled.

Subscriptions are the core of our revenue. Before the migration, iOS used the RevenueCat iOS SDK; Android hadn’t been implemented yet. If each platform maintained its own, behavioral consistency was at risk — the “does this free user see this feature?” check had to be identical logic on both platforms.

RevenueCat provides an official revenuecat/purchases-kmp SDK (we’re on 3.1.0) with an API very close to the native SDKs. We implemented the full MembershipRepository in commonMain:

class MembershipRepositoryImpl(
    private val userProfileRepository: UserProfileRepository
) : MembershipRepository {

    private val _membershipState = MutableStateFlow(MembershipState.FREE)
    override val membershipState: StateFlow<MembershipState> = _membershipState.asStateFlow()

    // PurchasesDelegate: receives real-time RevenueCat updates
    private val delegate = object : PurchasesDelegate {
        override fun onCustomerInfoUpdated(customerInfo: CustomerInfo) {
            val state = customerInfo.toMembershipState()
            emitAndPersist(state)  // update local state + sync to Supabase
        }
    }

    override fun configure(apiKey: String, appUserId: String?, debugMode: Boolean) {
        Purchases.configure(apiKey = apiKey) { this.appUserId = appUserId }
        Purchases.sharedInstance.delegate = delegate
    }

    override suspend fun purchase(packageIdentifier: String): Result<MembershipState> {
        return runCatching {
            val offerings = Purchases.sharedInstance.awaitOfferings()
            val pkg = offerings.current!!.availablePackages
                .find { it.identifier == packageIdentifier }!!
            val result = Purchases.sharedInstance.awaitPurchase(pkg)
            result.customerInfo.toMembershipState()
        }
    }

    // CustomerInfo → MembershipState mapping
    private fun CustomerInfo.toMembershipState(): MembershipState {
        val entitlement = entitlements["SugarLite Pro"]
        return if (entitlement?.isActive == true) {
            val expirationDate = entitlement.expirationDateMillis?.let {
                Instant.fromEpochMilliseconds(it)
            }
            val tier = if (expirationDate == null) MembershipTier.LIFETIME
                       else MembershipTier.PREMIUM
            MembershipState(tier = tier, expirationDate = expirationDate, isActive = true)
        } else {
            MembershipState.FREE
        }
    }
}

Key design:

  • Single source of truth: _membershipState is the canonical membership state. RevenueCat real-time updates flow through PurchasesDelegate.onCustomerInfoUpdated, which automatically updates this state.
  • Persisted to Supabase: Every state change is also written to user_profiles.membership_type, ensuring the backend stays in sync.
  • Unified across platforms: The configure() method takes an apiKey parameter. iOS and Android each pass their own API key; the rest of the logic is 100% shared.

On the iOS side, initialization is a single line:

Shared.CloudSourceProviderKt.configureRevenueCat(
    apiKey: Constants.revenueCatApiKey,
    appUserId: uid,
    debugMode: isDebug
)

Building on Part 1’s five principles, here’s what the subsequent migration taught us:

  1. ViewModels CAN be migrated — with SKIE. The StateFlowAsyncSequence conversion is smooth enough that dual-maintaining ViewModels is no longer necessary. The iOS side still needs a thin StateHolder for @Published bridging, but that’s typically under 50 lines per ViewModel — trivially cheap.
  2. Choose KMP-first for local storage. Room KMP is production-ready: 14 entities, 13 DAOs, and 12 schema versions all work reliably. New local storage requirements should go straight into Room KMP — don’t reach for platform-specific solutions (SwiftData, SharedPreferences, etc.).
  3. Don’t over-abstract with expect/actual. Our 5 declarations cover UUIDs, device language, platform info, DB paths, and DI wiring — and that’s enough. HealthDataSyncRepositoryIos is intentionally a stub because trying to force HealthKit and Health Connect into the same interface would make the code harder to read, not easier.
  4. RevenueCat KMP is worth adopting. The official KMP SDK covers the core flow (login, purchase, restore, offerings), and PurchasesDelegate’s real-time push mechanism works well. A few advanced features (like Paywalls) can be supplemented at the platform layer when needed.
  5. SKIE makes the bridging layer virtually disappear. CloudSourceProvider just pulls instances from Koin and exposes function signatures — zero glue code. suspend automatically becomes async, Flow automatically becomes AsyncSequence. New team members seeing import Shared + try await find it surprisingly natural.

This article is based on the real migration of SugarLite. If you’re interested in KMP cross-platform development, download the app and give it a try.


This post is based on the real migration process of the sugarlite project.

Related Content