Files

12 KiB

Full Restructure: Extracting All Platform Entry Points

This guide covers the complete extraction of platform-specific entry points from a monolithic composeApp module into dedicated per-platform application modules. This is the most thorough migration path and results in a clean architecture where shared contains only cross-platform code.


Target Architecture

shared/                    # KMP library (all shared code)
  build.gradle.kts         # kotlin.multiplatform + com.android.kotlin.multiplatform.library
  src/
    commonMain/kotlin/     # Shared business logic + UI
    androidMain/kotlin/    # Android expect/actual implementations
    iosMain/kotlin/        # iOS expect/actual implementations

androidApp/                # Android application entry point
  build.gradle.kts         # com.android.application
  src/main/

desktopApp/                # Desktop (JVM) application entry point
  build.gradle.kts         # org.jetbrains.compose + application {}
  src/main/kotlin/

webApp/                    # Wasm/JS web application entry point
  build.gradle.kts         # kotlin.multiplatform + wasmJs target
  src/wasmJsMain/kotlin/

iosApp/                    # iOS application (Xcode project, usually already separate)
  iosApp.xcodeproj/

Desktop Extraction

Create desktopApp/build.gradle.kts

plugins {
    alias(libs.plugins.kotlinJvm)
    alias(libs.plugins.composeMultiplatform)
    alias(libs.plugins.composeCompiler)
}

dependencies {
    implementation(project(":shared"))
    implementation(compose.desktop.currentOs)
    implementation(compose.runtime)
    implementation(compose.foundation)
    implementation(compose.material3)
}

compose.desktop {
    application {
        mainClass = "com.example.app.MainKt"

        nativeDistributions {
            targetFormats(
                org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
                org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
                org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb
            )
            packageName = "com.example.app"
            packageVersion = "1.0.0"

            macOS {
                iconFile.set(project.file("icons/icon.icns"))
            }
            windows {
                iconFile.set(project.file("icons/icon.ico"))
            }
            linux {
                iconFile.set(project.file("icons/icon.png"))
            }
        }
    }
}

Move Desktop Entry Point

composeApp/src/desktopMain/kotlin/com/example/app/main.kt
  --> desktopApp/src/main/kotlin/com/example/app/main.kt

Update to call shared code:

// desktopApp/src/main/kotlin/com/example/app/main.kt
package com.example.app

import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import com.example.shared.App

fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        title = "My App"
    ) {
        App()
    }
}

Remove Desktop from shared

In shared/build.gradle.kts, remove the jvm("desktop") target entirely. The desktop target only needs to exist in desktopApp.

Before (in composeApp):

kotlin {
    jvm("desktop")
    // ...
    sourceSets {
        val desktopMain by getting {
            dependencies {
                implementation(compose.desktop.currentOs)
            }
        }
    }
}
compose.desktop {
    application {
        mainClass = "com.example.app.MainKt"
        nativeDistributions { ... }
    }
}

After (in shared):

kotlin {
    // jvm("desktop") -- REMOVED
    // No desktop target in shared module
    // No compose.desktop block
}

If you have shared JVM code that both Android and Desktop use, you have two options:

  1. Keep a jvm() target in shared (without the application {} block) and use intermediate source sets.
  2. Put all shared code in commonMain and rely on the JVM dependency from desktopApp.

Web/WasmJS Extraction

Create webApp/build.gradle.kts

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.composeMultiplatform)
    alias(libs.plugins.composeCompiler)
}

kotlin {
    wasmJs {
        browser {
            commonWebpackConfig {
                outputFileName = "app.js"
            }
        }
        binaries.executable()
    }

    sourceSets {
        wasmJsMain.dependencies {
            implementation(project(":shared"))
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)
        }
    }
}

Move Web Entry Point

composeApp/src/wasmJsMain/kotlin/com/example/app/main.kt
  --> webApp/src/wasmJsMain/kotlin/com/example/app/main.kt

Update to call shared code:

// webApp/src/wasmJsMain/kotlin/com/example/app/main.kt
package com.example.app

import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.CanvasBasedWindow
import com.example.shared.App

@OptIn(ExperimentalComposeUiApi::class)
fun main() {
    CanvasBasedWindow(canvasElementId = "ComposeTarget") {
        App()
    }
}

Move Web Resources

composeApp/src/wasmJsMain/resources/index.html
  --> webApp/src/wasmJsMain/resources/index.html

Update index.html if the output JS filename changed.

Remove WasmJS from shared

In shared/build.gradle.kts, remove the wasmJs {} target:

kotlin {
    // wasmJs { ... } -- REMOVED
}

If you need shared Wasm-compatible code, keep wasmJs() in shared as a library target (no binaries.executable(), no browser {} config).


iOS Handling

iOS is typically already a separate Xcode project. The main considerations during restructure:

Framework Export Stays in shared

// shared/build.gradle.kts
kotlin {
    listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
        it.binaries.framework {
            baseName = "Shared"  // Update if renamed from "ComposeApp"
            isStatic = true
        }
    }
}

Update Xcode Project

If the module was renamed from composeApp to shared:

  1. Framework import: Change import ComposeApp to import Shared in all .swift files (must match baseName in the framework config).

  2. Gradle task path: Update the Run Script build phase in project.pbxproj (or via Xcode > Build Phases):

    # In Xcode Build Phases > Run Script
    cd "$SRCROOT/.."
    ./gradlew :shared:embedAndSignAppleFrameworkForXcode
    
  3. App struct name: If the SwiftUI @main struct was named after the old module (e.g., ComposeAppApp), rename it to something appropriate for your project.

  4. Framework search paths: Update Build Settings if they reference the old module directory path.

  5. Cocoapods (if used): Update the pod spec name:

    // shared/build.gradle.kts
    kotlin {
        cocoapods {
            name = "Shared"
            summary = "Shared KMP module"
            // ...
        }
    }
    

Module Rename: composeApp to shared

1. Rename the Directory

mv composeApp shared

2. Update settings.gradle.kts

// Before
include(":composeApp")

// After
include(":shared")
include(":androidApp")
include(":desktopApp")
include(":webApp")

3. Update Cross-Module Dependencies

Search all build.gradle.kts files for references to :composeApp:

// Before
implementation(project(":composeApp"))

// After
implementation(project(":shared"))

4. Update .idea / Workspace Files

If using IntelliJ/Android Studio, the IDE may cache the old module name. Either:

  • Delete .idea/ and re-import
  • Or manually update .idea/modules.xml and related files

Variant: Native UI (sharedLogic + sharedUI Split)

For projects where each platform has its own native UI and only business logic is shared:

sharedLogic/                # Pure KMP library (no Compose)
  build.gradle.kts          # kotlin.multiplatform + com.android.kotlin.multiplatform.library
  src/
    commonMain/kotlin/      # ViewModels, repositories, models, networking
    androidMain/kotlin/     # Android-specific implementations
    iosMain/kotlin/         # iOS-specific implementations

sharedUI/                   # Optional: Compose Multiplatform UI
  build.gradle.kts          # kotlin.multiplatform + com.android.kotlin.multiplatform.library + compose
  src/
    commonMain/kotlin/      # Shared composables
    androidMain/kotlin/     # Android-specific composables

androidApp/                 # Native Android app
  build.gradle.kts
  src/main/                 # Android UI (Compose or XML), depends on sharedLogic (and optionally sharedUI)

iosApp/                     # Native iOS app (SwiftUI/UIKit)
  # Depends on sharedLogic framework

sharedLogic/build.gradle.kts

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidKmpLibrary)
}

kotlin {
    android {
        namespace = "com.example.shared.logic"
        compileSdk = 35
        minSdk = 24
    }

    iosX64()
    iosArm64()
    iosSimulatorArm64()

    listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
        it.binaries.framework {
            baseName = "SharedLogic"
            isStatic = true
        }
    }

    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.ktor.client.core)
            implementation(libs.kotlinx.serialization.json)
        }
    }
}

This variant is useful when:

  • iOS uses SwiftUI and does not want Compose Multiplatform
  • Desktop is not a target
  • You want to minimize the shared surface area

Variant: Server (Backend Module)

For projects that include a Ktor/Spring server:

shared/                     # KMP library (shared models, API contracts)
androidApp/
iosApp/
server/                     # JVM server application
  build.gradle.kts          # kotlin("jvm") + ktor/spring plugin
  src/main/kotlin/

server/build.gradle.kts

plugins {
    alias(libs.plugins.kotlinJvm)
    alias(libs.plugins.ktor)         // or spring boot
    application
}

application {
    mainClass.set("com.example.server.ApplicationKt")
}

dependencies {
    implementation(project(":shared"))
    implementation(libs.ktor.server.core)
    implementation(libs.ktor.server.netty)
    implementation(libs.logback.classic)
}

The server module is a plain JVM module. It depends on :shared for common models and API contracts. It is unaffected by the AGP 9.0 migration except that:

  • If shared previously had a jvm() target that the server depended on, verify it still exists after restructuring.
  • If shared was renamed, update the dependency path.

settings.gradle.kts -- Final State

rootProject.name = "MyProject"

pluginManagement {
    repositories {
        google {
            content {
                includeGroupByRegex("com\\.android.*")
                includeGroupByRegex("com\\.google.*")
                includeGroupByRegex("androidx.*")
            }
        }
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}

include(":shared")
include(":androidApp")
include(":desktopApp")
include(":webApp")
// include(":server")    // if applicable

Quick Checklist

  • Create androidApp/ with pure Android application plugin (see MIGRATION-APP-SPLIT.md)
  • Create desktopApp/ with compose desktop plugin and application {} block
  • Create webApp/ with wasmJs target and binaries.executable()
  • Move main() functions from composeApp/src/{platform}Main/ to respective app modules
  • Move compose.desktop.application {} config to desktopApp
  • Move wasmJs { browser {} } config to webApp
  • Rename composeApp to shared
  • Convert shared to KMP library plugin (com.android.kotlin.multiplatform.library)
  • Remove platform app targets from shared (keep only library targets)
  • Update all settings.gradle.kts includes
  • Update all project(":composeApp") references to project(":shared")
  • Update Xcode project (framework name, Gradle task path, Swift imports)
  • Verify each app module builds independently
  • Run all platform targets to confirm functionality