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.

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 Frameworks

Layer responsibilities:

LayerDirectoryAllowed ImportsConstraints
ViewViews/SwiftUI onlyMust not access Repository directly
ViewModelViewModels/Foundation onlyMust not import Supabase/HealthKit/SwiftData
DomainDomain/FoundationDefine Protocol, UseCase, Service
DataRepositories/ + Data/Any frameworkThe 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.

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.kts

Key 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:

  1. isStatic = true: A static framework avoids dynamic library signing issues in Xcode Cloud builds.
  2. 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.

This is the most critical step โ€” moving all Supabase calls scattered across iOS repositories down into shared/src/commonMain/kotlin/.

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,
    // ...
)

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...
    }
}

All CloudSources are registered in the sharedModule:

val sharedModule = module {
    single { SupabaseClientProvider.client }
    single { FoodReferenceCloudSource(get()) }
    single { BloodSugarCloudSource(get()) }
    // ...
}

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:

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().

In the SwiftUI entry point’s init():

init() {
    KoinHelperKt.doInitKoin()
    AppLogger.database.info("KMP Koin initialized")
}

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.

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.

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 --version

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:embedAndSignAppleFrameworkForXcode

This 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.

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.

Looking back, we followed a few principles:

  1. DTOs/Models first: Data models have the fewest platform dependencies โ€” sink them first.
  2. Repository Protocols next: Interfaces define contracts; implementations can stay on iOS for now.
  3. UseCases/Services after that: Pure computation logic is a natural fit for cross-platform.
  4. ViewModels stay put: Keep @Observable on iOS to preserve the SwiftUI reactive experience.
  5. 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.

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