From Monolith iOS to Kotlin Multiplatform: SugarLite's Incremental Migration
SugarLite started as a pure iOS app built with SwiftUI + MVVM + Supabase Swift SDK. When the Android version became a priority, copying the business logic over to Kotlin would have doubled the maintenance burden โ network layer, data models, and business rules all needed dual-platform upkeep. That’s why we chose Kotlin Multiplatform: the shared/ module carries all cross-platform business logic, while the iOS side only keeps SwiftUI and system framework calls (HealthKit, SwiftData, WidgetKit).
This post walks through the entire migration โ from architectural refactoring to CI/CD adaptation, the pitfalls we hit, and what we learned along the way.
๐๏ธ Pre-Migration: MVVM Clean Architecture Refactoring
Before introducing KMP, we did an architectural refactor. The core goal was straightforward: decouple ViewModels from any external frameworks so that swapping out the underlying layer for Kotlin wouldn’t ripple upward.
The resulting dependency flow:
View โ ViewModel โ Protocol โ Repository โ External FrameworksLayer responsibilities:
| Layer | Directory | Allowed Imports | Constraints |
|---|---|---|---|
| View | Views/ | SwiftUI only | Must not access Repository directly |
| ViewModel | ViewModels/ | Foundation only | Must not import Supabase/HealthKit/SwiftData |
| Domain | Domain/ | Foundation | Define Protocol, UseCase, Service |
| Data | Repositories/ + Data/ | Any framework | The only layer allowed to import Supabase, SwiftData |
The value of this refactoring became crystal clear later on: when we swapped the Supabase client from Swift SDK to the KMP shared module, the ViewModel layer required zero changes โ it only depends on interfaces like FoodReferenceRepositoryProtocol, not on whether the implementation is Swift or Kotlin.
๐ฆ Introducing the KMP Project Baseline
After the architecture cleanup, we introduced KMP. The project root structure became:
โโโ iosApp/ # Original iOS project moved here
โ โโโ BloodSugarApp/ # SwiftUI source
โ โโโ BloodSugarApp.xcodeproj
โ โโโ ci_scripts/ # Xcode Cloud build scripts
โโโ composeApp/ # Compose Multiplatform (Android entry point)
โโโ shared/ # KMP shared module
โ โโโ src/commonMain/kotlin/ # Cross-platform business logic
โ โโโ src/androidMain/kotlin/
โ โโโ src/iosMain/kotlin/
โโโ gradle/
โโโ build.gradle.kts
โโโ settings.gradle.ktsKey configuration in shared/build.gradle.kts:
kotlin {
android { ... }
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
api(libs.supabase.auth)
api(libs.supabase.postgrest)
api(libs.supabase.storage)
api(libs.ktor.client.core)
implementation(libs.koin.core)
}
androidMain.dependencies {
implementation(libs.ktor.client.cio)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}Two design decisions worth noting:
isStatic = true: A static framework avoids dynamic library signing issues in Xcode Cloud builds.api(libs.supabase.*): Supabase libraries are exposed as API dependencies. The iOS side could theoretically use them directly, but we hide the details behind a wrapper layer.
๐ Sinking the Data Layer: Supabase Swift SDK โ KMP Shared
This is the most critical step โ moving all Supabase calls scattered across iOS repositories down into shared/src/commonMain/kotlin/.
DTO Layer
Created @Serializable Kotlin DTOs for all 13 tables, using snake_case for field mapping:
@Serializable
data class FoodReferenceDto(
val id: String,
val name: String,
val name_en: String? = null,
val category: String,
val gi_value: Int? = null,
val calories: Double? = null,
// ...
)CloudSource Wrappers
Each domain entity gets a CloudSource that encapsulates CRUD operations:
class FoodReferenceCloudSource(private val client: SupabaseClient) {
suspend fun fetchAll(): List<FoodReferenceDto> {
return client.from("food_reference").select().decodeList()
}
suspend fun fetchPage(
page: Int, pageSize: Int,
searchText: String?, category: String?
): List<FoodReferenceDto> {
// Build PostgREST query...
}
}Koin DI Module
All CloudSources are registered in the sharedModule:
val sharedModule = module {
single { SupabaseClientProvider.client }
single { FoodReferenceCloudSource(get()) }
single { BloodSugarCloudSource(get()) }
// ...
}๐ Incremental Adoption on iOS
KMP migration doesn’t have to happen all at once. Our strategy was: “new features go straight into KMP; old code gets replaced gradually.” Here’s how we migrated FoodReferenceRepository:
Bridging Layer
First, expose Koin container instances to Swift. In iosMain:
fun doInitKoin() {
startKoin { modules(sharedModule) }
}In commonMain, expose accessor functions:
private object KoinProvider : KoinComponent {
fun provideFoodReferenceCloudSource(): FoodReferenceCloudSource = get()
}
fun getFoodReferenceCloudSource(): FoodReferenceCloudSource =
KoinProvider.provideFoodReferenceCloudSource()Kotlin top-level functions compile to Swift global functions, so the iOS side can directly call CloudSourceProviderKt.getFoodReferenceCloudSource().
App Launch Initialization
In the SwiftUI entry point’s init():
init() {
KoinHelperKt.doInitKoin()
AppLogger.database.info("KMP Koin initialized")
}Repository Transformation
Before (Swift Supabase SDK):
import Supabase
final class FoodReferenceRepository {
private var supabaseClient: SupabaseClient
func fetchAll() async throws -> [FoodReferenceDTO] {
let dtos: [FoodReferenceDTO] = try await supabaseClient
.from("food_reference")
.select()
.execute()
.value
return dtos
}
}After (KMP CloudSource):
import Shared
final class FoodReferenceRepository {
private let cloudSource: FoodReferenceCloudSource
init() {
self.cloudSource = CloudSourceProviderKt.getFoodReferenceCloudSource()
}
func fetchAll() async throws -> [FoodReferenceDTO] {
let kmpDtos = try await cloudSource.fetchAll()
return kmpDtos.map { FoodReferenceDTO(fromKmp: $0) }
}
}Only three things changed: import Supabase โ import Shared, raw PostgREST queries โ wrapped cloudSource.fetchAll(), and a new fromKmp DTO conversion helper.
This pattern can be replicated for every repository. The team can migrate one at a time, in priority order.
โ๏ธ Xcode Cloud CI/CD Adaptation
The biggest challenge after introducing KMP was CI/CD. Xcode Cloud build environments don’t include Java or Gradle by default, yet our iOS builds now depend on the Shared.framework compiled by Kotlin/Native.
ci_pre_xcodebuild.sh: Environment Setup
We handle Java and Gradle installation in Xcode Cloud’s ci_pre_xcodebuild.sh phase:
#!/bin/sh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT_DIR"
export GRADLE_USER_HOME="$ROOT_DIR/.gradle"
export HOMEBREW_NO_AUTO_UPDATE=1
resolve_java_home() {
if [ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/java" ]; then
echo "$JAVA_HOME"
return 0
fi
for version in 21 17 11; do
if JAVA_CANDIDATE=$(/usr/libexec/java_home -v "$version" 2>/dev/null); then
if [ -x "$JAVA_CANDIDATE/bin/java" ]; then
echo "$JAVA_CANDIDATE"
return 0
fi
fi
done
if command -v brew >/dev/null 2>&1; then
for formula in openjdk@17 openjdk; do
if brew list --versions "$formula" >/dev/null 2>&1; then
JAVA_CANDIDATE="$(brew --prefix "$formula")/libexec/openjdk.jdk/Contents/Home"
if [ -x "$JAVA_CANDIDATE/bin/java" ]; then
echo "$JAVA_CANDIDATE"
return 0
fi
fi
done
fi
return 1
}
ensure_java() {
if JAVA_CANDIDATE="$(resolve_java_home)"; then
export JAVA_HOME="$JAVA_CANDIDATE"
else
echo "[Xcode Cloud] Java not found. Installing openjdk@17 via Homebrew..."
brew install openjdk@17
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home"
fi
export PATH="$JAVA_HOME/bin:$PATH"
}
ensure_java
echo "[Xcode Cloud] JAVA_HOME=$JAVA_HOME"
java -version
./gradlew --versionBuild Phase Script: Compiling the Kotlin Framework
A Run Script Phase added in Xcode’s Build Phases:
#!/bin/sh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT_DIR"
# Xcode Preview optimization: skip Gradle build
if [ "${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED:-}" = "YES" ]; then
echo "Skipping Gradle build due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED=YES"
exit 0
fi
# Resolve JAVA_HOME (same logic as ci_pre_xcodebuild.sh)
# ...
export PATH="$JAVA_HOME/bin:$PATH"
./gradlew :shared:embedAndSignAppleFrameworkForXcodeThis script runs on every Xcode build, invoking the Gradle task to compile Kotlin/Native and embed the framework. For local development, we skip it during Xcode Previews via the OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable to avoid slowing things down.
Gotchas
Sentry Script vs. Widget Extension Build Dependency Cycle: A cryptic build failure turned out to be an implicit dependency between Sentry’s “Upload Debug Symbols” script and the Widget Extension’s Embed phase. The fix was simple โ just drag the Sentry script to run after the Embed phase.
Unstable Java Runtime Resolution: Java installation paths vary across Xcode Cloud macOS images. Our final resolve_java_home() uses a multi-level fallback: system JAVA_HOME โ /usr/libexec/java_home โ Homebrew โ brew list. This redundant lookup strategy ensures Java is found on any image version.
๐ Migration Strategy: What We Learned
Looking back, we followed a few principles:
- DTOs/Models first: Data models have the fewest platform dependencies โ sink them first.
- Repository Protocols next: Interfaces define contracts; implementations can stay on iOS for now.
- UseCases/Services after that: Pure computation logic is a natural fit for cross-platform.
- ViewModels stay put: Keep
@Observableon iOS to preserve the SwiftUI reactive experience. - New features go straight into KMP. For existing code, evaluate whether it can move to
shared/when it gets touched. Don’t migrate for migration’s sake โ avoid the “big bang” rewrite.
๐ References
This post is based on the real migration process of the SugarLite project.
If you feel that this article has been helpful to you, your appreciation would be greatly welcomed.
Sponsor