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.
📱 Sinking ViewModels: Breaking Our “Keep Them Put” Promise
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:
suspendfunctions → Swiftasync throwsStateFlow<T>/Flow<T>→ SwiftAsyncSequence(backed by generatedSkieSwiftAsyncSequence)- Enums and sealed classes → Swift
enum
With SKIE, consuming a KMP ViewModel from SwiftUI feels almost as natural as a native @Observable.
The KMP ViewModel Pattern
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:
- One ViewModel, one
uiState: StateFlow. No multiple independent flows — that would force the iOS side to manage multiple subscriptions. - State is a single
data class. On Android,collectAsStateWithLifecycle()destructures it directly. On iOS, SKIE converts it to anAsyncSequence— onefor awaitgets all state in a single receive. - All mutations use
_uiState.update { it.copy(...) }. Never_uiState.value = ...—updateis atomic and avoids race conditions. - Dependencies are top-level use-case functions, not Koin. ViewModel instantiation is just
HomeViewModel()— zero DI overhead.
iOS Consumption via SKIE
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.
Android Consumption
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/After: The Migration Payoff
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
StateHolderis 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.
💾 Room KMP: From SwiftData to a Cross-Platform Local Database
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.
Why Room KMP
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.
AppDatabase Core Setup
@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.
DatabaseFactory: The expect/actual Pattern
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.
One-Time SwiftData → Room Migration
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.
Database Schema Migrations
The Room database evolved from version 3 to 12 across 9 incremental migrations. Here are some notable ones:
| Migration | Versions | Change |
|---|---|---|
MIGRATION_3_4 | 3→4 | Added sync fields (is_dirty, deleted, sync_version) to food_glycemic_responses |
MIGRATION_5_6 | 5→6 | Removed food_name column. Since SQLite doesn’t support DROP COLUMN, used the table-rebuild pattern: new table → copy data → drop old → rename |
MIGRATION_11_12 | 11→12 | Removed 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)")
}
}Gotcha: RTrees and ZIP Format
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.
🔧 expect/actual: Elegant Platform Abstractions
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:
1. Platform Info
// 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.
2. Device Language
// commonMain
expect fun getDeviceLanguageCode(): String
// iosMain
actual fun getDeviceLanguageCode(): String = NSLocale.currentLocale.languageCode ?: "en"
// androidMain
actual fun getDeviceLanguageCode(): String = java.util.Locale.getDefault().languageUsed for: Supabase Edge Function Accept-Language headers and default language selection on first launch.
3. UUID Generation
// 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).
4. DI Platform Module
// commonMain
expect val platformModule: ModuleThis 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.
5. Room Constructor (Code-Generated)
// 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.
expect/actual Design Principles
5 declarations, fewer than 200 lines of actual code total. We follow two rules:
- 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
expectinterface would produce awkward signatures.HealthDataSyncRepositoryIosis currently a stub (all methods return empty) — it’ll be swapped for a real implementation when HealthKit integration is prioritized. - Keep granularity small.
expect fun getDeviceLanguageCode(): Stringis much easier to maintain thanexpect class LocaleManager. Eachexpectdoes exactly one thing; each platform implementation has zero extra dependencies and is trivial to test.
🔌 SKIE & CloudSourceProvider: Kotlin-Swift Interop Best Practices
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→ Swiftasync throwsFlow<T>/StateFlow<T>→ SwiftAsyncSequence- All types get Swift-readable names
The CloudSourceProvider Pattern
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()returnsFlow— the iOS side usesfor awaitto react to database changes in real time. - One-shot operations use
suspend:saveBloodSugarRecord()— Swift side callstry awaitdirectly. @Throwsmatters: Without it, Kotlin exceptions surface as genericNSErroron the Swift side. With it, Swift cancatchby specific exception type.
The Swift Call-Site Experience
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.
⚠️ Kotlin-Swift Exception Handling: Making Errors Flow Across Languages
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?
What Happens Without @Throws — More Than “Can’t Tell Them Apart”
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 -> BloodSugarRecordDtoThe reality is much more dangerous. Per the official KMP documentation, when a suspend function lacks @Throws:
CancellationException→ propagates asNSError(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.
@Throws: A Safety Whitelist
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.
CancellationException Must Be Re-Thrown
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.
When NOT to Use @Throws
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:
- Exceptions inside
Flowpropagate through the Flow itself — SKIE wraps them intoAsyncSequenceiteration, and the Swift side catches them infor try await. Crucially, a Flow exception terminates the stream but does not crash the program. - One-shot
suspendlookups (likegetBloodSugarRecordById) are risky without@Throws. While “not found” →nulldoesn’t need exceptions, a Room database error (e.g. corruption) would crash the app. The safe pattern is to wrap intry/catchon the Kotlin side and convert tonull:
// 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.
Practical Takeaways
- Every function exposed across the language boundary needs
@Throwswith 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. - Be precise AND complete with your
@Throwstypes. Don’t write@Throws(Exception::class)— that’s just as unhelpful as no@Throwsat all. SeparateHttpRequestException,RestException, andIOException. At the same time, make sure every exception type that could actually be thrown is in the list. - Never swallow
CancellationException. Addif (e is CancellationException) throw ein every generic Kotlin-sidecatchblock. Even when it’s declared in the@Throwslist, swallowing it still prevents proper coroutine cancellation. - If you want to skip
@Throwson a query function, absorb all exceptions on the Kotlin side first. Convert them tonull, a default value, or a sealed result type — make sure nothing propagates across the boundary unhandled.
💰 RevenueCat KMP: Unified Subscription Management
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:
_membershipStateis the canonical membership state. RevenueCat real-time updates flow throughPurchasesDelegate.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 anapiKeyparameter. 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
)📋 Migration Strategy: What We Learned (Continued)
Building on Part 1’s five principles, here’s what the subsequent migration taught us:
- ViewModels CAN be migrated — with SKIE. The
StateFlow→AsyncSequenceconversion is smooth enough that dual-maintaining ViewModels is no longer necessary. The iOS side still needs a thinStateHolderfor@Publishedbridging, but that’s typically under 50 lines per ViewModel — trivially cheap. - 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.).
- 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.HealthDataSyncRepositoryIosis intentionally a stub because trying to force HealthKit and Health Connect into the same interface would make the code harder to read, not easier. - 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. - SKIE makes the bridging layer virtually disappear.
CloudSourceProviderjust pulls instances from Koin and exposes function signatures — zero glue code.suspendautomatically becomesasync,Flowautomatically becomesAsyncSequence. New team members seeingimport Shared+try awaitfind 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.
📚 References
- Part 1: sugarlite’s KMP Incremental Migration
- Room KMP Official Documentation
- SKIE — Swift Kotlin Interface Enhancer
- RevenueCat KMP SDK
- Kotlin expect/actual Documentation
This post is based on the real migration process of the sugarlite project.
Related Content
If you feel that this article has been helpful to you, your appreciation would be greatly welcomed.
Sponsor