DEV Community

Shiva Thapa
Shiva Thapa

Posted on Originally published at Medium on

We Called Our App Multiplatform. 848 Lines Actually Were.

The rebuild of a production KMP app to 93% measured shared code — with real numbers from both codebases.

Representation image

Production Compose Multiplatform — Part 2 of 5. Part 1 — real iOS 26 Liquid Glass over shared Compose UI.

There’s a moment in every long-lived codebase where you finally run the measurement you’ve been avoiding. Mine was a line count.

Our Kotlin Multiplatform app — a production farming app, two platforms, years of shipped features — shared 848 lines of Kotlin with iOS. Out of roughly 215,000 lines in the project. Not 848 files. Lines. The rest was an Android app, an iOS app, and a label.

This series is the story of the rebuild: how GeoKrishi Farm went from that number to 46 Gradle modules with 93% measured shared code, live on both stores, without users ever noticing the swap. This first part is the case study — what the fork looked like, why it happened to a reasonable team, and what the rebuild changed, in numbers I can defend line by line.

The App, for Context

GeoKrishi Farm serves farmers in Nepal: crop planning, weather, market prices, a farm diary, expert chat, calculators — on both Android and iOS. The audience shapes the engineering. Most users run low-end Android devices (minSdk is still 24) on intermittent connectivity, in Nepali. Every decision in this series was made for that person.

How a Multiplatform App Becomes Two Apps

Nobody decides to build the same app twice. It happens one sprint at a time.

The first version adopted KMP without adopting shared UI. Android screens were Jetpack Compose; iOS — with nothing to render Compose — rebuilt every screen in SwiftUI. 64,690 lines of Swift grew beside the Kotlin. Our Home screen existed as an 1,138-line Kotlin file and, separately, as a 1,484-line SwiftUI view with its own 1,249-line view model. Two implementations, two sets of bugs, two review cycles for every feature.

And the “shared” Kotlin module? With no consumer on the iOS side beyond a thin networking seam, code drifted to where it was easiest to write: androidMain. By the end, 82% of the Kotlin in the shared module was Android-only. commonMain held 30,000 lines; androidMain held 119,000.

Here’s the uncomfortable part: the build never objected. It compiled fine. It shipped fine. There is no compiler error for your architecture quietly forked eight months ago.

The Debt Underneath

The fork was the visible problem. Underneath it, a single-module codebase built at delivery speed had accumulated the usual passengers:

  • One god API class. 1,151 lines, 78 endpoints, 66 DTO imports — plus sixteen sibling API classes extending it. Inheritance where composition should have been.
  • No dependency injection. Repositories constructed their own HttpClient through a default argument. Every repository, its own connection pool, silently.
  • Environment switching by comment. Which backend a build talked to depended on which constant line was uncommented that day.
  • Navigation through a global mutable. A singleton held the last child navigator so notification taps could push screens from anywhere — under an all-caps comment warning future readers not to do exactly this. The team knew. Pressure writes code like that anyway.
  • Two state-management idioms at once. 73 AndroidX ViewModels and 52 Voyager ScreenModels doing the same job, depending on who wrote the screen and when.
  • Screens past 2,000 lines , and over a thousand commented-out lines kept “just in case.”

I can list these freely because I co-built that codebase. This isn’t a review of someone else’s mess — it’s an audit of my own, with two more years of experience. If any of that list feels familiar, that’s the point: none of it is exotic, and none of it trips a compiler. It’s what a single-module codebase becomes under sustained deadline pressure with nobody owning the shape of the whole.

What the Old App Got Right

Fairness matters, because the old app wasn’t incompetent — it was unowned.

The expect/actual boundaries were genuinely tidy. Dependencies were current, with a proper version catalog. The Ktor client was configured correctly in one place. Crashlytics shipped with symbol upload wired for release builds. Individual decisions were fine.

What no one held was the shape of the whole. Every architectural question — where does this go, what may depend on what — was answered locally, per feature, per deadline. The root cause wasn’t skill. The architecture never had an owner.

Refactor or Rewrite

We chose a rewrite, and I want to be precise about why, because “rewrite” is usually the wrong answer.

The deciding factor was UI. As long as iOS rendered SwiftUI, every shared-code improvement had a ceiling: the most expensive duplication — screens — was untouchable. Compose Multiplatform removed that ceiling, but adopting it meant every screen changed anyway. Once every screen changes, the incremental-refactor advantage is gone, and you’re free to fix the module structure, the DI, and the navigation in the same motion.

The second factor: the old app had to keep shipping. A refactor-in-place would have put the release train and the restructuring in the same repository, fighting. Instead we ran two repos — the old one releasing, the new one growing feature by feature with the old screens as the spec. The rewrite history shows exactly this rhythm: a foundation phase (build system, core modules, design system first), a stretch where the old app took priority, then a long porting push to the finish line.

Not a big-bang rewrite. A parallel one, with an end date.

The Numbers, Honestly Counted

“Shared code percentage” is usually a vibe. Here’s mine, counted with wc -l on source files, build directories excluded — old codebase → new:

  • Gradle modules: 1 → 46
  • Kotlin actually reaching iOS: 848 lines → 119,742 lines of commonMain
  • androidMain Kotlin: 119,168 lines → 3,941
  • Swift: 64,690 lines → 993
  • Shared, as a share of the whole codebase: ~14% → 93%
  • Implementations per screen: 2 → 1

The new number includes every screen — the UI is Compose Multiplatform, so there is no SwiftUI layer quietly excluded from the math. The entire native iOS footprint is eight Swift files: an app delegate, a Compose host, and a handful of bridges. The 7% that remains platform-specific is deliberate: 37+ explicit expect/actual seams and two thin app shells, each of which earns its existence.

Structure enforces the rest. Features can’t import features — module boundaries make cross-feature coupling a compile error, not a code-review hope. Domain layers are framework-free. One injected HttpClient behind repository interfaces. Type-safe navigation with no global mutables. Full deep-link support on both platforms — custom scheme, verified universal links, push payloads — resolved by one shared router.

The usual objection to that number is that a 93%-shared app must feel generic on iOS. It doesn’t have to — Part 1 is a real system UITabBar rendering Apple's iOS 26 Liquid Glass, floating above this exact shared host, with the same single NavHost behind it. Native chrome cost us a bounded, deliberate trade, not a fork.

And it’s fast where it counts: the app ships baseline and startup profiles, generated from the real first-launch journey on a low-end device like the ones our users own. The app became 18% faster with lots of other improvement within.

The Part Nobody Blogs About

The rewrite shipped as an ordinary update on the same store listing — next versionCode, same application ID, every existing install upgraded in place. That required migration engineering that rarely gets written up: recovering stored credentials the old app had saved under an R8-obfuscated key, wiping iOS Keychain state that outlives uninstalls, and keeping years of old push payloads and share links routing correctly.

Users updated, opened the app, and were still logged in — on a codebase sharing 93% of its lines instead of 848. A rewrite the user notices is a failed rewrite.

That swap is Part 3 of this series, because it deserves its own piece.

What I’d Tell a Team Standing Where We Stood

Measure real sharing, quarterly. Lines compiled for both targets, divided by total. If the number surprises you, the fork already happened. (I later built Aalekh partly out of this scar — the real architecture lives in the Gradle dependency graph, not in any diagram.)

Decide the UI question deliberately. KMP without shared UI is a legitimate architecture — but it must be a decision, with the duplication cost priced in. If nobody decided, you’ll pay the cost anyway and get drift for free.

Give the architecture an owner before the first feature. Written rules, and module boundaries that enforce the important ones. Convention doesn’t survive deadlines; implementation(projects.core.network) does.

Keep the old app releasable until the day you don’t need it. It buys calendar time honestly, and it keeps the pressure off the new codebase to ship before its foundations are done.

What’s Next

Part 3: shipping the swap — credential migration under R8 obfuscation, Keychain hygiene, and deep-link backward compatibility. Then the architecture tour, the native-SDK bridge patterns, and baseline profiles for a low-end fleet.

If you’re staring at your own 848-line number right now — or you’ve run this migration and landed somewhere different — I’d genuinely like to hear how it went in the comments.

Drop a ❤️ if this was useful — it helps more developers find the series.

Top comments (0)