Dec 17, 2025·8 min read

Kotlin Multiplatform: where shared code helps and hurts

Kotlin Multiplatform works best when you share domain rules, API models, and tests. See where it saves time and where teams hit friction.

Kotlin Multiplatform: where shared code helps and hurts

Why shared code turns into a fight

Shared code usually turns into a fight when two teams want different things from the same codebase. Android often wants speed: write the business rules once, ship faster, fix one place. iOS often wants control: keep behavior close to the app, match platform norms, and avoid surprises before release. Both sides make sense.

The tension starts when one shared module decides work for both apps. Kotlin Multiplatform can cut duplicate work, but it also changes who owns the logic. A shared module often ends up with one default owner, and the other team feels like a guest in code that still ships to its users.

That shift matters more than people expect. Before sharing, each team could change its own validation, pricing rules, or error handling without much debate. After sharing, every small change needs agreement on naming, data models, failure states, and release timing. The work moves from writing code twice to negotiating once, over and over.

One bug also hits harder. If the shared code gets a subscription check wrong or breaks token refresh, Android and iOS can both fail on the same day. Code reviews get tense fast when one mistake can break two apps instead of one.

The daily arguments usually come from bad boundaries, not bad teammates. If shared code returns text ready for the screen, one team loses control over wording and formatting. If each app still maps network errors in its own way, duplicate logic comes back. Then people argue in every pull request about small things that never stay small: where date formatting lives, who owns retries, which loading state belongs in shared code, and when a platform rule should stay local.

Shared code works best when the boundary is boring and obvious. Domain rules, request signing, and tests often fit well. UI behavior, analytics quirks, and platform specific presentation logic often do not. If a team keeps fighting over the same file, that file is probably in the wrong place. A smaller shared layer usually saves more time than a bigger one that nobody wants to touch.

What to share first

The safest shared code gives the same answer on both platforms every time. Price calculation, discount rules, input validation, and state changes fit well because they do not depend on Android screens or iOS view controllers. When a team fixes a tax rule or a trial eligibility check once, both apps stop drifting.

Data models also belong near the top of the list. If both apps read a subscription, invoice, or user profile in the same way, you remove a whole class of bugs. One side no longer treats a field as optional while the other assumes it always exists.

Kotlin Multiplatform works best when the first shared code is plain business logic. This is shared domain logic, not shared UI. Pure functions are the best first move because they take input, return output, and stay away from device APIs. A function like canStartTrial(accountAge, previousTrialUsed, country) is boring code, and that is exactly why it should move first.

A small first module is usually enough:

  • pricing and discount rules
  • form validation
  • subscription status and entitlement checks
  • shared data models and simple mappers

That first module should stay small enough to rewrite without drama. This sounds cautious, but it saves time later. If this small slice already creates naming fights, awkward build steps, or slow debugging, you learn that early, before shared code spreads into the rest of the app.

UI flows should wait. They carry platform habits, navigation rules, and lots of small edge cases. Teams often argue there because Android and iOS users expect slightly different behavior, even when the business rule stays the same. Share the rule that says a plan is available. Leave the screen logic on each platform until the shared layer earns trust.

A simple filter helps: if both apps should behave the same way and tests can prove it, share it first. If the code depends on screen structure, gestures, or platform-specific polish, keep it local for now.

Where networking fits best

Networking is often the cleanest place to share code in Kotlin Multiplatform. Android and iOS may render screens in different ways, but they usually call the same API, send the same auth data, and decode the same responses.

That shared layer should own the rules that must stay consistent. Build requests there. Parse responses there. Keep auth logic there too, especially token refresh, header rules, and the small checks that decide whether the app should retry or ask the user to sign in again.

A good split is simple: platform code starts the network work, shared code decides what the request means.

Keep OS behavior outside

Background sync is where teams often overreach. iOS and Android wake apps differently, limit background work differently, and expose different system APIs. If you try to hide all of that in shared code, the abstraction gets awkward fast.

Let Android handle WorkManager, notifications, connectivity listeners, and app lifecycle hooks. Let iOS handle BGTaskScheduler, push-triggered refresh, and its own lifecycle events. Once the platform decides "now is the time to sync," the shared layer can take over and run the actual API calls.

That split saves arguments. Each app keeps the parts the operating system controls, and both apps still reuse the business rules behind the network flow.

Hide HTTP details early

Library choices change more often than teams expect. A small wrapper around your HTTP client gives you room to swap libraries later without touching domain code. The rest of the app should not care whether requests travel through Ktor today or something else next quarter.

Error handling also belongs in one place. Map low-level failures into a short set of app errors and reuse them everywhere. A timeout, no connection, expired session, bad server response, or permission error should mean the same thing on both platforms.

In a subscription app, both Android and iOS might call the same "restore purchases" endpoint. Shared code can build the request, attach auth, parse the response, and turn network failures into one rule set. The platform app still decides how to schedule that call and how to present the error to the user.

Test coverage before you expand

Before you move more code into a shared module, lock down the behavior you already depend on. Tests do that better than comments or team memory. If a subscription rule says a paused account should not renew, write that test before you share the billing logic across Android and iOS.

This matters because shared code spreads bugs faster. A bad rule in one native app is annoying. The same bad rule in Kotlin Multiplatform can break both apps on the same day.

Start with unit tests around business rules. Focus on the parts that are easy to misunderstand later: trial length, renewal dates, grace periods, discount rules, access checks, and state changes after payment failure. These tests should read like plain examples of how the product works.

Contract tests come next. They catch the quiet breakages that show up after an API change. A field becomes null. A new enum value appears. The backend sends an empty list instead of omitting a property. If your shared networking and parsing code cannot handle that, both apps fail in the same place.

A small set of contract checks usually covers a lot:

  • normal API responses with expected fields
  • missing or null fields
  • unknown values from the server
  • paging and empty states
  • error bodies and timeout handling

Run the same shared tests on every pull request. Do not wait for a release branch or a nightly build. Fast feedback keeps small mistakes small. If the shared module changes, the full shared test suite should run every time.

Fix flaky tests early. Teams ignore a suite they do not trust, and then the tests stop protecting anything. Most flaky tests come from the same few problems: real clocks, random data, network calls that are not fully mocked, and hidden state between tests.

If one test fails once every twenty runs, treat it like a product bug. Clean it up before the module gets bigger. That is usually the point where shared code still saves time instead of starting fights.

What to leave on Android and iOS

Review Your Shared Boundary
Get a practical second opinion on what to share in KMP and what to keep native.

Kotlin Multiplatform works best when it lowers repeat work. It starts causing friction when teams try to share parts of the app that depend on platform habits, store rules, or device APIs.

UI is the clearest example. Android and iOS users expect different screen flow, spacing, gestures, and small interaction details. If both teams force one shared screen pattern, they usually spend more time arguing over edge cases than building features.

Navigation should stay local for the same reason. Deep links, back behavior, modal screens, and system navigation all feel a bit different on each platform. A shared route map can look tidy on paper, but local control is easier to debug and easier to change.

Some features almost always belong in platform code:

  • camera and photo access
  • in-app payments and store billing
  • push notification setup and handling
  • OS-specific permissions
  • accessibility fixes tied to native controls

Accessibility deserves special care. Small fixes often depend on the exact native component, screen reader behavior, and focus order in that app. If a VoiceOver issue appears on iOS, the iOS team should fix it close to the screen, not wait for a shared abstraction to grow.

Caching and offline behavior sit in the middle. Shared domain logic can decide what data the app needs and when it expires. Each app can still tune how it stores that data, when it preloads, and how aggressive it should be offline. That matters when Android users expect broader background work, while iOS puts tighter limits on what runs behind the scenes.

A good rule is simple: share decisions, keep device behavior local. Share business rules, request models, and tests. Leave anything that touches native UI, store flows, sensors, notifications, or accessibility close to Android and iOS.

That split keeps both teams fast. It also avoids the worst KMP networking mistake: pushing platform-specific app behavior into shared code just because the code can compile there.

A step-by-step rollout plan

Start with one feature both apps already ship. Pick something small, stable, and a little boring. Price rules, account status checks, or form validation usually make better first candidates than login, payments, or push notifications.

Before anyone moves code, draw the boundary on one page. Split it into three areas: shared code, Android code, and iOS code. If a piece touches UI, device permissions, platform SDKs, or app store behavior, keep it on the native side for now.

Kotlin Multiplatform goes smoother when the first shared layer is mostly pure logic. That means data models, validation rules, calculations, and small mapping functions. Teams usually argue less about those parts because the expected behavior is already clear.

A good rollout order looks like this:

  1. Freeze the current behavior with tests.
  2. Move models and business rules into shared code.
  3. Move small helper functions and mappers.
  4. Add networking helpers only after the rules settle.
  5. Ship one release, then stop and review.

The testing step belongs before each move, not after it. If Android and iOS already behave the same today, write tests that prove it. Then move one slice of code and run the same checks again. This feels slower for a day or two, but it saves a week of "why is iOS different now?"

Networking deserves a cautious start. Share request building, response parsing, and common error mapping first. Leave platform-specific HTTP setup, auth storage, and anything tied to native libraries where it already lives until the shared layer has earned some trust.

After the first release, pause on purpose. Ask both teams what caused friction: build times, debugging, naming, test setup, or unclear ownership. If the pain is small and the code stayed stable, share the next slice. If the pain is high, keep the experiment narrow and fix the process before you move more.

That pause matters. Shared code should remove duplicate work, not start a weekly argument.

A simple example from a subscription app

Choose What Stays Native
Get help separating business rules from UI, billing, and OS behavior.

A small subscription app is a good fit for Kotlin Multiplatform when the team shares the rules behind the product, not the screens. In one common setup, Android and iOS both offer monthly and yearly plans, a free trial, and a few feature gates. The hard part is not drawing the paywall. The hard part is making sure both apps agree on who gets access, when a trial ends, and what happens after a refund or renewal.

The team puts plan rules, entitlements, and API models in shared code. That includes logic such as "premium users can export data" or "trial users lose access after 7 days unless billing confirms a paid plan." This is shared domain logic, and it is exactly the kind of code that tends to drift when each app owns its own copy.

Networking also fits well here. The app fetches subscription status, product metadata, and account flags from the backend through one shared client. That keeps request models, response parsing, and error handling in one place. KMP networking helps most when both apps talk to the same API and need the same business rules after each response.

The billing flow stays native. iOS keeps StoreKit purchase and restore flows. Android keeps Google Play Billing. That split is usually calmer for the team because each platform still follows its own rules, SDK quirks, and review requirements.

Shared tests pay off fast. In this app, one test checks a user who starts a trial at 11:30 PM, crosses midnight, and upgrades before the full 7 days pass. The shared test catches a bug where the trial expired a day early on one platform because of a date boundary mistake. Without shared tests, Android might pass while iOS fails after release.

The team also skips shared UI. That choice saves time. Product can still tune each paywall for platform style, and engineers avoid long arguments about navigation, state wrappers, and view layer compromises. Kotlin Multiplatform helps this app because it removes duplicate logic, not because it forces Android and iOS to look or behave the same.

Mistakes that waste a sprint

Teams usually lose time when they share the wrong code first. UI is the usual trap. A screen may look similar on Android and iOS, but navigation, state handling, animations, and small platform habits differ enough to turn one shared screen into a daily argument.

Start with the rules behind the screen instead. Pricing logic, validation, subscription status, and request models usually fit shared code better. The view layer often does not.

Another mistake is hiding real platform differences under wrappers that keep growing. Camera access, push permissions, local storage, and background work do not behave the same way on both platforms. A fake common API can turn into a pile of condition checks where nobody knows which layer should own the fix.

That confusion gets worse when teams move code without tests. If a discount rule already works on Android, copying it into shared domain logic without unit tests is a bet, not a plan. One bad merge can break Android and iOS at the same time, and then the team spends two days arguing about whether the bug came from the old code or the new shared module.

KMP test coverage matters more than the size of the shared module. A small shared package with solid tests saves time. A large one with weak tests burns it.

Ownership causes another slow fight. If one platform team makes every decision about shared code, the other team ends up reviewing choices they did not help shape. That usually leads to quiet resistance, delayed reviews, and workarounds on the side.

Shared code needs shared rules. Android and iOS engineers should both have a say on API shape, error handling, and when platform-specific code is the better choice.

Kotlin Multiplatform also goes wrong when a team treats it like a rewrite. Rewrites feel neat on a planning board, then drag on because every old assumption gets reopened. A narrow refactor works better: move one stable use case, prove it saves effort, then decide if the next piece belongs in shared code.

A simple filter helps. If the code changes for business reasons, share it first. If it changes because Apple and Google do things differently, leave it on each platform.

Quick checks before you share more

Audit Shared Networking
Check auth, error mapping, and platform split before one bug hits both apps.

Teams usually share too much right after the first win. One shared validator works, then someone wants shared screens, shared state, and half the app. That is when Kotlin Multiplatform starts saving less time and creating more arguments.

A quick filter helps. If you cannot answer these questions clearly, keep the code separate for now.

  • Can both apps explain the rule in the same plain sentence?
  • Do Android and iOS hit the same backend endpoints with the same request shape?
  • If the shared code breaks, does it block sign-in, payment, or another flow users need right away?
  • Can your team debug Kotlin issues on both platforms without waiting for one specialist?
  • Did one person take ownership of the shared module and its release process?

The first question matters more than it looks. If Android says "a trial starts after email verification" and iOS says "a trial starts after first launch," you do not have one rule. You have two product choices wearing the same label. Shared domain logic works when the business rule is truly the same.

Networking is similar. If both apps call the same endpoints, parse the same fields, and handle the same errors, sharing that client often makes sense. If iOS uses one auth flow and Android uses another, a shared networking layer can turn simple differences into messy condition checks.

Risk matters too. A shared bug spreads fast. If one mistake can break checkout or cancel access for paying users, add tests before you move that code into a common module. KMP test coverage should grow before the shared surface grows. Otherwise you trade duplicate code for duplicate panic.

Team skill is the part many teams skip. If only one developer can trace a Kotlin stack across Android and iOS, the shared module becomes a bottleneck. The code may be fine. The workflow is not.

Ownership keeps the module healthy. One owner does not mean one person writes everything. It means one person decides versioning, reviews risky changes, and says no when a feature does not belong there.

If most answers are "yes," share the next small piece. If two or three answers are shaky, wait a sprint and clean that up first.

What to do next

Pick one feature your team already maintains twice and hates touching. A good first target is something like subscription rules, trial eligibility, pricing checks, or form validation. If the same bug keeps showing up on Android and iOS, that is usually a better starting point for Kotlin Multiplatform than a brand new feature.

Keep the scope narrow. Share the domain rules, maybe one networking layer, and the tests around them. Leave UI, navigation, and platform quirks in the native apps for now. That gives you a fair test: do you fix bugs once, ship a bit faster, and keep behavior consistent across both apps?

Write a short rollback plan before the work starts:

  • Decide which parts stay native if the shared module slows the team down.
  • Set a review point after one sprint or one release.
  • Name who can stop the rollout if it creates more friction than it removes.
  • Define one or two signs of success, like fewer duplicate fixes or better test coverage.

This plan matters. When a module starts creating drag, people need a quick way to step back instead of arguing for another week.

If the team keeps fighting about the split, pause expansion and get a technical review. Most arguments about Android iOS shared code are not really about Kotlin. They come from unclear ownership, weak test coverage, or a bad boundary between shared and native code.

Oleg Sotnikov can help with that review in a practical way. As a fractional CTO, he helps teams map the shared boundary, review the rollout, and decide what should stay native because it will stay cheaper and simpler there. Sometimes one outside review is enough to turn a messy debate into a small next step the team can actually test.

Start with one painful feature. Keep the rollback path short. If the same argument keeps coming back, bring in a fresh technical opinion before the next sprint.

Kotlin Multiplatform: where shared code helps and hurts | Oleg Sotnikov