One Kotlin codebase, three platforms: building Multidex with Compose Multiplatform
Multidex is a Pokédex app that ships to Android, iOS and Desktop (JVM) from one Kotlin codebase — Kotlin 2.4, Compose Multiplatform 1.9, Material 3 Expressive. It's live on Google Play, and this post is the honest write-up I wish I'd read before starting: how the modules are cut, what shared beautifully, and where the platform seams still show.
The module graph
The build is split into layered Gradle modules, with dependencies pointing strictly
downward. Platform apps sit at the top; everything below them is a Kotlin Multiplatform
library compiled for android, desktop (JVM), and
iosArm64/iosSimulatorArm64:
:android Android app — signing, Firebase, Play Billing, splash
:ios Xcode wrapper — Swift entry point, embeds the Kotlin framework
:app shared Compose shell, navigation graph, desktop entry point
:pokemon domain models, repositories, Compose screens
:data PokéAPI DTOs and mappers
:preferences DataStore-backed settings and cache
:common theming, UI building blocks, cross-cutting utilities
Data flows PokéAPI → Ktor client (:data) → repositories
(:pokemon) → Compose screens (:app), with responses cached
through DataStore so previously opened entries render offline. The per-target wiring
lives in one multiplatform() convention function in
buildSrc, so adding a module doesn't mean re-declaring four targets
every time.
What shared better than expected
The UI. Basically all of it. The same Compose screens run on a phone, an iPad and a desktop window. Material 3 Adaptive handles the layout jumps — list-detail panes on tablets and desktop, single pane on phones — from one navigation graph (Navigation 3 with the ViewModel integration). I budgeted weeks for "iOS UI work" that never materialized.
Theming driven by artwork. Each Pokémon's detail screen extracts a palette from its official artwork (via Kolor) and re-seeds the Material color scheme, so every entry gets its own accent — a Charizard page feels warm, a Squirtle page cool. This is pure shared Kotlin: one implementation, three platforms, no platform branches.
Networking and caching. Ktor + kotlinx.serialization + DataStore work identically everywhere. The offline story — cache every opened entry, serve it when the network drops — required zero platform-specific code.
Where the seams show
Everything around the app, not in it. The shared code was the easy 80%.
The per-platform 20% is services: Firebase (Analytics, Crashlytics, Performance) is
wired only in :android; Play Billing for the Shiny Charm in-app purchase
is Android-only, so the paywall in :common talks to an
expect/actual billing abstraction; on-device AI rewriting of
dex descriptions uses Google ML Kit GenAI, which exists only on Android — so it's a
capability the shared UI queries, not assumes.
The iOS wrapper is thin but real. :app exports a framework
that a small Xcode project embeds. It's little Swift, but it's still a second build
system, a second signing story, and a second set of failure modes. CI that builds both
is non-negotiable.
Version discipline. Compose Multiplatform, Material 3 Adaptive,
Navigation 3, Koin with KSP annotations — the stack moves fast and the compatible-set
matrix is unforgiving. A single libs.versions.toml catalog plus
buildSrc conventions is what keeps upgrades a one-line change instead of
an archaeology dig.
Developer experience notes
Compose Hot Reload on the desktop target changed how I work: UI iteration happens in a desktop window with sub-second reloads, and Android/iOS become verification targets rather than the inner loop. If you're doing KMP UI work and not exploiting the JVM target for iteration speed, you're leaving the biggest DX win on the table.
Koin with Koin Annotations (KSP) gives DI that's declared once in shared code. The trade-off is KSP build-time cost across four targets — noticeable, but worth it over hand-wiring.
Would I choose KMP again?
For an app like this — content-heavy, UI-driven, no deep platform integrations at its
core — without hesitation. The shared fraction of the codebase is far past 90%, the
app feels native-fast on all three targets, and the desktop build exists purely
because it was nearly free. The honest caveat: the moment your app's value lives in
platform services (billing, ML, push, health APIs), you're back to writing careful
expect/actual seams — KMP shrinks that work, it doesn't
erase it.
The app is free on Google Play. Questions or corrections — email me.