If you are an Android developer, you know Jetpack. It changed how we build Android apps. But that was long ago. Today, the ecosystem is shifting again. We aren't just building for one platform anymore. We are building for the world. Let me show you how to take the Jetpack libraries you know and use them to fuel a new generation of applications that run everywhere.
From Jetpack to a unified architecture
First, let's ground ourselves. What exactly is Jetpack? It's more than just a bag of libraries. It is Google's opinionated answer to Android development. It unbundled features from the OS, so we could update our apps without waiting for Android system updates. It gave us backward compatibility. Most importantly, it gave us guidance. It stopped the wild west of Android development and brought us a standard way to build.
Before Jetpack, we lived in an era of fragmentation. New features were tied to the OS. If you wanted the latest UI on an older phone, you were out of luck. Eventually, Google introduced the Support Library to fix this. It sort of worked, but it was a mess. We had v4 support, v7 appcompat, v13... it was dependency hell. We needed a reboot.
That reboot came in two steps. In 2017, Google announced Android Architecture Components at I/O—ViewModel, Room, Lifecycle, LiveData—and they went 1.0 stable that November. That was the how to architect piece. Then, at I/O 2018, came Jetpack: the umbrella name and the migration to androidx.*. Eventually, everything moved to the new namespace. Libraries were strictly unbundled—own versions, own cycles, semantic versioning so we could reason about compatibility. So: Architecture Components in 2017, Jetpack and androidx in 2018.
Today, we have solved one problem but created another: The Jetpack Jungle. There are now over 130 artifacts in the suite. We have historical baggage. Deprecated libraries still sit next to modern ones—for example lifecycle-extensions, which Google deprecated and replaced with separate lifecycle-runtime, lifecycle-viewmodel, and so on, but it still turns up in old tutorials. Or security-crypto, deprecated with no clear official successor, but still in the docs. We have three different ways to do background work, two ways to do navigation, and endless UI helpers. The challenge is no longer how do I do this? but which of these 130 libraries should I actually use?
To survive the jungle, we need a map. A golden path, if you will. It's the curated, opinionated stack that Google actually recommends today. Stick to it and you avoid the jungle. UI: reactive, driven by state. Presentation: ViewModels that survive config changes. Navigation: a single graph defining your flow. Data: Room for database, DataStore for preferences. That stack is the gold standard for Android—but it no longer stops there. Everything on that list—Compose, ViewModels, Navigation, Room, DataStore—is available in Kotlin Multiplatform. You can keep the same architecture and the same APIs and compile them for iOS, Desktop, and the Web. One architecture, same code, everywhere.
That stack has a few key pieces. The UI layer is Compose Multiplatform. It is the exact same declarative, reactive paradigm you use on Android, just unbundled from the OS. You write composables, and they react to state. On Android, it's Jetpack Compose. On iOS and Desktop, it uses the Skia graphics engine to draw pixel-perfect UI while running natively on the hardware. This means you aren't learning three different UI frameworks. You aren't context-switching. You are taking your existing Android expertise and applying it to the entire world.
For state and lifecycle: Android didn't invent the ViewModel. Cross-platform frameworks like Xamarin were using the MVVM pattern to share logic between iOS and Android long before Jetpack existed. The good news is that Android and KMP have finally adapted this proven standard. You put your ViewModel in shared code. It survives configuration changes. It holds your state. It's the industry standard for a reason, and now it's the standard for our shared code, too.
Navigation is another piece. It used to be where cross-platform architectures fell apart. But not here. With Navigation Compose, your navigation graph travels with you. You declare your routes, your arguments, your back stack, and your deep links once in shared code. Type-safe. Whether it's an Android Activity, an iOS View Controller, or a Desktop Window, the platform code is just a thin container hosting your navigation. You aren't reimplementing routing logic three times. You define the flow once, and it drives the UI everywhere.
For local database storage, we use Room. If you haven't used it before, Room is a full Object-Relational Mapper (ORM) wrapping SQLite. It lets you define your data as simple Kotlin objects and maps them automatically to database tables. Its superpower is compile-time verification. Unlike many ORMs that fail at runtime, Room checks your SQL queries against your schema as you build. In this architecture, Room runs natively on iOS, Android, and Desktop, giving you a single, type-safe data layer with the performance of raw SQLite.
For configuration and preferences: every app needs to store something—whether it's a dark mode toggle, a session token, or feature flags. Usually, this means writing one implementation for iOS using UserDefaults and another for Android using SharedPreferences. With DataStore, we unify this. It is a modern, multiplatform key-value store built entirely on Kotlin coroutines. It is asynchronous by default, preventing UI freezes on any platform. You write your preference logic once in shared code, and it handles the native storage details for you.
How it fits together
So, how does this fit together? You have one shared core. This module contains almost everything: your Compose UI, your Navigation graph, your ViewModels, and your Database. Surrounding that, you have thin native shells. The Android app, the iOS app, and the Desktop app. They do little more than initialize the process and host the shared UI. They might add a splash screen or handle push notifications, but the actual application—the screens and the logic—is shared. This means no duplicate business logic and no synchronization issues between platforms.
The shared module is the structural center of your application. Inside commonMain, you place the core components we just went through. But when you need to interact with specific OS APIs—like file paths, Bluetooth, or system intents—you use the expect / actual pattern. You declare the interface in the shared code, and the platform module provides the implementation. This keeps your business logic pure and testable, ensuring platform details don't leak into your core architecture.
The platform modules are intentionally thin. On Android, you have a single Activity calling setContent. On iOS, you have a standard View Controller that hosts the shared Compose UI. On Desktop, it's just a main() function. Crucially, this is where you initialize your Dependency Injection—like Koin. You wire it up once at startup, and then the rest of the application logic is fully shared.
Dependency injection often raises a question: Isn't Hilt the recommended Jetpack DI? Yes, for Android it's fantastic. But Hilt relies heavily on Dagger and Java annotation processing, which simply does not work on iOS or Desktop. Critically, Google has not yet said anything about Hilt going multiplatform. There is no roadmap. So, to keep our shared code clean and compile-safe today, we use a pure Kotlin solution like Koin. You define your modules in shared code; each platform supplies the actuals (e.g. where's the data directory). One container, init once per platform. It effectively becomes the standard for this architecture by necessity.
To make this concrete: in CMP Unit Converter, the Koin module lives in commonMain. The database and ViewModels are provided there; the platform supplies the DB path via expect / actual:
// commonMain/.../di/AppModule.kt
val appModule = module {
single<AppDatabase> { getRoomDatabase(getDatabaseBuilder()) } // getDatabaseBuilder() is expect/actual
viewModelOf(::AppViewModel)
viewModelOf(::TemperatureViewModel)
viewModelOf(::DistanceViewModel)
}
In practice: CMP Unit Converter
Enough theory—let's walk through the full picture. I built an app called CMP Unit Converter to prove this stack works. It converts temperatures and distances, stores your history, and remembers your preferences. It runs on Android, iOS, and Desktop, and it's built entirely on the stack we just went through: Compose for UI, ViewModels for state, Room for history, and Koin for injection. Let's tear it apart and see how it works.
When you open the project, you'll notice the structure right away. It follows the modern AGP 9 guidelines. Gradle 9.1 and AGP 9 are the baseline. In this repo the modules are: shared (the library), composeApp (the Android app), desktopApp, and iosApp is a separate Xcode project. Your Android app—here, composeApp—is its own module: just application code, no Kotlin Multiplatform plugin. The shared module uses the new com.android.kotlin.multiplatform.library plugin. In shared's build.gradle.kts you configure the Android target with androidLibrary { } inside the kotlin { } block, not the old top-level android { } block. The app module uses AGP's built-in Kotlin, so you don't apply the Kotlin Android plugin there. Android is just another target, like Desktop or iOS. This clean separation is key: the app is a thin shell, the library does the heavy lifting. It's a bit of a mental shift if you had one composeApp doing everything, but it pays off in clearer separation and faster builds.
In code it looks like this:
// shared/build.gradle.kts
plugins {
alias(libs.plugins.androidKotlinMultiplatformLibrary)
// ...
}
kotlin {
androidLibrary { namespace = "..."; ... }
// ...
}
// composeApp/build.gradle.kts
dependencies {
implementation(project(":shared"))
// ...
}
The shared UI lives in commonMain—for example, ConverterScreen.kt. That's the app's main screen: standard Compose code. But notice the resources—we don't have XML strings for Android and Localizable.strings for iOS. We put everything in the composeResources folder. We access them in Kotlin using the generated Res object—Res.string or Res.drawable. At compile time, CMP bundles these into the correct native format for each platform. You write the UI once, and it looks native everywhere.
// commonMain/.../ConverterScreen.kt
@Composable
fun ConverterScreen(
navigationState: NavigationState,
viewModel: AbstractConverterViewModel,
scrollBehavior: TopAppBarScrollBehavior
) { ... }
// Res API (e.g. AppIcons.kt)
val Thermostat: DrawableResource get() = Res.drawable.ic_thermostat
// In composables: stringResource(Res.string.app_name)
The brain of the app is the converter ViewModels—we have TemperatureViewModel and DistanceViewModel, both in shared code and registered with Koin. When the UI loads, it asks for the ViewModel via koinViewModel(). It doesn't care if it's running on a Pixel or an iPhone. The ViewModels survive configuration changes on Android and manage state on iOS seamlessly. Same classes everywhere.
// commonMain/.../di/AppModule.kt
val appModule = module {
single<AppDatabase> { getRoomDatabase(getDatabaseBuilder()) }
viewModelOf(::TemperatureViewModel)
viewModelOf(::DistanceViewModel)
}
// In a composable (e.g. App.kt)
viewModel = koinViewModel<TemperatureViewModel>()
For data, we use Room and DataStore. The app saves your conversion history in a SQLite database using Room. It remembers your last selected unit using DataStore. The only platform-specific code here is a tiny expect/actual function to tell the app where to save the file on disk (e.g. Application Support on iOS, getDatabasePath on Android). Everything else—the DAOs, the queries, the preference keys—is shared. We aren't fighting with CoreData or SharedPreferences.
// commonMain/Platform.kt
expect fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase>
// androidMain: path from context.getDatabasePath("...")
actual fun getDatabaseBuilder() = Room.databaseBuilder<AppDatabase>(
context = context,
name = context.getDatabasePath("CMPUnitConverter.db").absolutePath
)
// iosMain: path from getDirectoryForType(DirectoryType.Database) → Application Support
The platform apps are just entry points. On Android, the Application class calls initKoin at startup (e.g. in CMPUnitConverterApp); MainActivity then calls setContent. On iOS, ComposeView inside a ViewController. On Desktop, main() launches the window. There's one little gotcha on iOS. When we export our Kotlin initKoin function to Swift, Kotlin/Native renames it to doInitKoin because it returns Unit. It's a small quirk, but knowing it saves you a "Method Not Found" error. Call doInitKoin in your Swift AppLifecycle and you're good to go.
In Kotlin we have:
// commonMain/.../di/KoinApp.kt
fun initKoin(config: KoinAppDeclaration? = null) {
startKoin {
includes(config)
modules(appModule)
}
}
On iOS, Swift sees the same function with the "do" prefix:
// iOS (Swift). Kotlin exports Unit-returning functions with "do" prefix:
KoinAppKt.doInitKoin()
Takeaways and resources
One of the most common pitfalls is version mismatch. Your shared module pulls in Compose Multiplatform and a specific Compose runtime. Your Android app uses activity-compose to call setContent. If activity-compose was built for a different Compose version, you get crashes like NoSuchMethodError at runtime. So align activity-compose with the Jetpack Compose version that Compose Multiplatform uses—there’s a compatibility table in the docs. Also keep the Compose Compiler plugin version in sync with your Kotlin Multiplatform plugin. When in doubt, check the Compose Multiplatform and AGP compatibility pages before you upgrade. That habit pays off.
A few other things help in practice. If you’re starting fresh, use the AGP 9 structure from day one. Migrating an old single-module app to the new structure works, but it’s more work. And expect / actual is your friend: put every platform quirk behind an expect declaration, implement it per platform, and document the odd ones—like doInitKoin on iOS—so your future self or your team don’t “fix” what isn’t broken. That keeps the shared code clean and the architecture solid.
To recap: the story—where Jetpack came from, the jungle of 130+ artifacts, the Golden Path map, and that it all runs beyond Android. The lineup—Compose Multiplatform, ViewModel, Navigation, Room, DataStore—all available in KMP. How it fits together: one shared module, thin platform shells, entry points, dependency injection. And the AGP 9 structure in a real project, CMP Unit Converter—one codebase, multiple binaries.
A few topics were left out of this overview. For example, testing: your shared module is highly testable. ViewModels, repositories, use cases—you can run them in commonTest or on the JVM. Room works with an in-memory database; same DAOs and entities. For expect/actual you supply test implementations. The Kotlin and Android docs and the CMP Unit Converter repo show how. Version alignment was mentioned above: when you upgrade, align activity-compose with the Compose version your shared module uses, and check the compatibility pages. And what's not in KMP yet: WorkManager, CameraX, a few others. For those you stay in the Android app module or look for community options. The stack we focused on is the part that's officially supported and where you'll spend most of your time.
Where is this going? Google and JetBrains are actively moving Jetpack beyond Android. We're not there yet for every library, but the trend is clear: the same APIs you use on Android are being made available in KMP. The Golden Path stack we've talked about is at the front of that wave. So investing in this architecture now—Compose, ViewModel, Navigation, Room, DataStore—is not a bet on a niche. It's aligning with where the ecosystem is headed. One architecture, many platforms.
For your own projects, here's a practical checklist. UI: Compose Multiplatform. State: ViewModel and Lifecycle Runtime in KMP. Navigation: Navigation Compose in KMP. Persistence: Room and DataStore in KMP. Structure: one shared module, thin Android, iOS, and Desktop apps—and if you're on Android, use the AGP 9 layout with the new KMP library plugin. Dependency injection: Koin or Hilt, init once at app startup on each platform. Stick to this stack and you avoid the jungle. You get one architecture that runs everywhere.
For more detail, the Kotlin and Compose Multiplatform docs on kotlinlang.org and jetbrains.com are the place to start. For the Android side—the new KMP library plugin, AGP 9, and the migration steps—developer.android.com has the official guides. The Compose Multiplatform compatibility page tells you which Jetpack Compose version lines up with which CMP version. When in doubt, check it before you upgrade—it saves head-scratching. And there are samples: CMP Unit Converter is one; the Kotlin and Android teams publish more. You're not on your own—the docs and the samples are there to back you up.
Top comments (0)