One Kotlin codebase, three platforms: building Multidex with Compose Multiplatform

Kunal Das · July 2026

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.

Lesson one: cut modules by responsibility, not by platform. The only platform-specific modules are the two entry points at the very top. Everything else — including nearly all UI — is shared.

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.