Kotlin Multiplatform can feel like an “all-in rewrite” decision—until you run a small, carefully scoped proof of concept. The fastest way to prove value is to share the pieces that are expensive to duplicate and easy to validate: API models + networking (and optionally a thin repository layer). This post walks you through the smallest KMP POC that still answers the only question that matters: “Will shared code reduce time, bugs, and drift in our Android + iOS apps?”
Quickstart: the smallest KMP POC you can ship this week
If you’re trying Kotlin Multiplatform for the first time, don’t start by sharing UI. Start by sharing the “plumbing” that’s duplicated today: JSON models, API calls, and error handling. This quickstart is designed to be small enough to finish quickly, but real enough to show measurable value.
Pick one real screen (one endpoint)
Choose a screen that both apps already have (or will have) and that calls an API endpoint. Keep it boring: list + detail, profile, “latest items”, search results.
- One endpoint (GET) + one response model
- One auth strategy (token/header)
- One failure mode (offline or 401)
- One success metric (time saved / fewer bugs)
Share only models + networking
The first POC should prove you can ship shared logic without slowing the team down. Keep platform UI native (Jetpack Compose / SwiftUI).
- Shared module:
shared(KMP) - Serialization:
kotlinx.serialization - HTTP client:
Ktorwith platform engines - Public API: a small
ApiorRepositoryinterface
Make integration friction visible
A POC isn’t just “can it compile?”—it’s “does it fit our day-to-day workflow?” Track the pain points you hit so you can make an informed go/no-go decision.
- Xcode integration: framework + build times
- Debuggability: stack traces, logging, errors
- Concurrency: coroutines ↔ Swift async/await
- Release flow: versioning + CI build
Define “POC success” up front
If success is vague, the POC will be debated forever. Decide what proves value before you write code.
| Signal | What to measure | What “good” looks like |
|---|---|---|
| Duplication | How many files/lines stop being duplicated | Models + networking shared end-to-end |
| Speed | Time to add a field / endpoint | One change in shared code, both apps update |
| Drift | Android/iOS behavior mismatch | Same parsing + same error mapping |
| Team fit | Build + debug + release friction | “Annoying but acceptable” (not “blocking”) |
If you only share one thing, share API models + parsing. Many cross-platform pains come from subtle parsing differences and mismatched default values. Shared models pay off immediately—even before you share any “business logic.”
Minimal shared module setup (Gradle)
This is the only “setup-heavy” part of the smallest Kotlin Multiplatform POC. Keep the dependency list tight: serialization + Ktor client + coroutines. Everything else can come later (DI, caching, analytics, persistence).
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
}
kotlin {
androidTarget()
// Generates an iOS framework you can import into Xcode.
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:YOUR_KTOR_VERSION")
implementation("io.ktor:ktor-client-content-negotiation:YOUR_KTOR_VERSION")
implementation("io.ktor:ktor-serialization-kotlinx-json:YOUR_KTOR_VERSION")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:YOUR_SERIALIZATION_VERSION")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:YOUR_COROUTINES_VERSION")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-okhttp:YOUR_KTOR_VERSION")
}
}
val iosMain by creating {
dependsOn(commonMain)
dependencies {
implementation("io.ktor:ktor-client-darwin:YOUR_KTOR_VERSION")
}
}
iosX64Main.dependsOn(iosMain)
iosArm64Main.dependsOn(iosMain)
iosSimulatorArm64Main.dependsOn(iosMain)
}
}
The fastest way to turn a POC into a “migration project” is to introduce lots of frameworks on day one. For the smallest POC, avoid persistence, DI frameworks, and advanced architecture until you’ve proven that the shared networking/model layer is worth it.
Overview
Kotlin Multiplatform (KMP) is best treated as an incremental engineering tool, not a belief system. The smallest POC that proves value should answer four questions:
- Can we share code that is currently duplicated? (models, parsing, error mapping, networking)
- Does it reduce drift? (Android and iOS behave the same for the same API)
- Is the integration experience acceptable? (build times, Xcode/Gradle friction, debugging)
- Can we keep UI native? (Compose + SwiftUI remain first-class)
What you will build in this post
A tiny shared module that exposes one clean API to both apps:
| Layer | Shared in POC? | Why/why not |
|---|---|---|
| Data models (DTOs) | Yes | Immediate ROI: one source of truth for parsing + defaults |
| Networking + error mapping | Yes | Eliminates “same endpoint, different behavior” bugs |
| Repository interface | Yes (thin) | Gives platforms a stable contract; keeps complexity low |
| Persistence/cache | No | Useful, but expands scope and adds more migration variables |
| UI | No | Native UI is a strength; avoid UI-sharing debates in a POC |
By the end, you’ll have a repeatable pattern: add an endpoint once, update both platforms, and keep platform UI and platform-specific UX behaviors completely native. That’s usually the “aha moment” that makes Kotlin Multiplatform feel worth it.
Don’t start by sharing everything. A first POC is not the time to rebuild your architecture, unify all domain logic, or solve every edge case. Keep it narrow, measurable, and reversible.
Core concepts
Before you write the POC, it helps to have a shared mental model. Kotlin Multiplatform is not “one codebase for two apps.” It’s a way to compile Kotlin into platform-specific artifacts while sharing selected modules. The goal is to share the parts that benefit from being identical, and keep the parts that benefit from being native.
1) The “public surface area” rule
Your KMP module has two audiences: Android and iOS. Anything you expose publicly becomes a contract you’ll support. A good smallest POC keeps the public API tiny: DTOs + one repository + a small sealed error type.
Good public API (POC-friendly)
data classDTOs (serializable)interfacerepository with 1–3 functions- Sealed error type (timeout/auth/offline)
- Pure Kotlin types (no platform types)
Risky public API (scope explosion)
- Platform-specific types leaking into common code
- Complex state machines and deep domain layers
- Multiple DI frameworks or platform-specific lifecycles
- “Helper” utilities that become a dumping ground
2) Common code vs platform code (where the line usually is)
A simple rule: share code where identical behavior is valuable; keep code native where platform UX matters. For most teams, the sweet spot is: networking + models + error mapping + (optional) light business rules.
A practical boundary map
| Concern | Share? | Reason |
|---|---|---|
| JSON parsing + DTOs | Yes | Prevents drift and duplicated model changes |
| HTTP calls + retries + headers | Yes | Same security + same reliability behavior |
| UI, animations, platform navigation | No | Native UX is a competitive advantage, not duplication |
| Permissions, camera, push notifications | No | Deeply platform-specific APIs and lifecycles |
| Analytics event names | Maybe | Sharing can prevent event drift, but keep it simple |
3) Ktor engines and why you need them
Ktor provides a common API, but each platform uses a different underlying HTTP engine.
That’s why the dependency setup includes ktor-client-okhttp for Android and ktor-client-darwin for iOS.
You write one HTTP client in common code; the platform selects the engine at compile time.
4) Coroutines, thread rules, and iOS expectations
Coroutines are a great fit for shared networking, but iOS integration is where POCs often get messy. You want a clear story for:
- How Swift calls suspend functions (callback wrappers or async/await interop)
- Where results are delivered (main thread for UI updates)
- How cancellation works (user navigates away, request should stop)
Keep shared code pure and boring. The more “platform-like” your shared layer becomes, the harder it is to maintain. Your POC should feel like a clean library both apps happen to use.
Step-by-step
This is a practical guide you can follow even if you already have existing Android and iOS apps. The steps below focus on the smallest Kotlin Multiplatform POC that proves value: one endpoint, shared models, shared networking, and native UI on both platforms.
Step 1 — Choose your POC slice (scope it like a feature flag)
Treat the shared module like an internal library you could remove later. Pick a slice that can be isolated.
- Prefer “read-only” first (GET) before “write” (POST/PUT)
- Pick a screen that exists on both platforms
- Avoid the most complex auth flow for the first try
- Decide where the shared module will be used (one feature only)
Step 2 — Define your contract: DTOs + errors + repository
The contract is the heart of the smallest POC. If you design it well, both apps will adopt it without fighting it. If you design it poorly, the POC will “work” but feel painful.
DTO design rules that reduce churn
- Use safe defaults for new fields (avoid breaking older clients)
- Prefer simple primitives (String/Int/Boolean) unless needed
- Use
@SerialNamefor API field names when required - Keep DTOs separate from UI models (platform can map)
Error mapping rules that prevent drift
- Map “offline” and timeouts explicitly
- Differentiate 401/403 from 5xx
- Provide a stable error type (sealed class)
- Never expose raw exceptions to UI layers
Step 3 — Implement shared networking + repository (the core POC)
Below is a compact “KMP POC” implementation that stays intentionally small: a Ktor client with JSON negotiation, a DTO, a sealed error type, and a repository that returns a typed result. You can grow this later (auth refresh, retries, caching), but don’t add those until you’ve proven the basic flow.
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class FeedItemDto(
val id: String,
val title: String,
@SerialName("created_at") val createdAt: String,
val author: String = "Unknown"
)
sealed class ApiError {
data object Offline : ApiError()
data object Timeout : ApiError()
data class Http(val code: Int, val message: String? = null) : ApiError()
data class Unknown(val message: String? = null) : ApiError()
}
sealed class ApiResult<out T> {
data class Ok<T>(val value: T) : ApiResult<T>()
data class Err(val error: ApiError) : ApiResult<Nothing>()
}
class ApiClient(
private val baseUrl: String,
private val tokenProvider: () -> String?
) {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
}
private val http = HttpClient {
install(ContentNegotiation) {
json(json)
}
}
suspend fun fetchFeed(): ApiResult<List<FeedItemDto>> = safeCall {
val token = tokenProvider()
val url = "$baseUrl/feed"
val response = http.get(url) {
if (token != null) header("Authorization", "Bearer $token")
header("Accept", "application/json")
}
ApiResult.Ok(response.body())
}
private suspend fun <T> safeCall(block: suspend () -> ApiResult<T>): ApiResult<T> {
return try {
block()
} catch (t: Throwable) {
// Keep the POC mapping intentionally simple. Expand later based on real failures.
val msg = t.message
ApiResult.Err(ApiError.Unknown(msg))
}
}
}
interface FeedRepository {
suspend fun loadFeed(): ApiResult<List<FeedItemDto>>
}
class FeedRepositoryImpl(private val api: ApiClient) : FeedRepository {
override suspend fun loadFeed(): ApiResult<List<FeedItemDto>> = api.fetchFeed()
}
This is enough to prove the two biggest wins: (1) you change parsing once and both apps match, and (2) you unify error behavior once and both apps stop drifting. Everything else (caching, DI, fancy retries) can be layered in after you’ve validated the workflow.
Step 4 — Integrate on Android (native UI, shared data)
The Android side should feel normal: call the shared repository from a ViewModel, map DTOs to UI models, and handle loading/error states exactly how you already do. The POC is successful when Android devs say “this feels like calling any other Kotlin library.”
Android integration checklist
- Create
ApiClientwith yourbaseUrland token provider - Call
loadFeed()from ViewModel (coroutines) - Map
FeedItemDto→ UI model - Handle
ApiResult.Errwith user-friendly messages
Step 5 — Integrate on iOS (make async feel native)
iOS integration success is less about networking and more about developer experience: can Swift call the shared module cleanly, cancel work when needed, and update UI on the main thread? For a smallest POC, you want a thin Swift wrapper that makes shared code feel like a normal async API.
import Foundation
import Shared // Your generated KMP framework/module name
@MainActor
final class FeedViewModel: ObservableObject {
@Published private(set) var items: [FeedItemDto] = []
@Published private(set) var errorText: String? = nil
@Published private(set) var isLoading: Bool = false
private let repo: FeedRepository
init(repo: FeedRepository) {
self.repo = repo
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
// In many setups, suspend functions are available as async in Swift.
// If your project exposes callbacks instead, wrap them here once and keep the rest of the app clean.
let result = try await repo.loadFeed()
if let ok = result as? ApiResultOk {
// ApiResultOk is the Swift-visible type name generated by KMP tooling.
self.items = ok.value as? [FeedItemDto] ?? []
self.errorText = nil
} else if let err = result as? ApiResultErr {
self.errorText = "Couldn’t load feed. Please try again."
print("KMP error:", err.error)
} else {
self.errorText = "Unexpected response."
}
} catch {
self.errorText = "Request failed: \(error.localizedDescription)"
}
}
}
If the generated Swift API feels awkward, wrap it once in a tiny Swift adapter and keep the rest of the iOS code clean. The POC is about validating value and workflow—not about forcing all developers to think in KMP internals.
Step 6 — Add a tiny “drift test” to prove the payoff
Drift is the hidden cost Kotlin Multiplatform can eliminate: Android and iOS behave differently because parsing, defaults, and error mapping are duplicated. A great POC makes drift reduction visible.
What to validate (fast)
- Same JSON payload parses the same on both platforms
- Unknown fields don’t break clients (ignoreUnknownKeys)
- New optional field doesn’t crash older UI
- Error mapping produces the same user-facing behavior
How to show value to the team
- Pick one endpoint that recently drifted between apps
- Fix behavior once in shared code
- Ship the fix to both apps with one change
- Write down the before/after time and risk
Step 7 — Decide your next move (without turning it into a rewrite)
After the POC, you should have real answers: build friction, integration pain points, and whether the “one change updates both apps” workflow feels good. Now choose a measured next step:
Reasonable next steps after a successful smallest POC
- Add 2–3 more endpoints using the same pattern
- Extract auth handling (token refresh) if needed
- Add a lightweight caching layer (only if product needs it)
- Introduce test coverage for parsing + error mapping
- Create a simple release/versioning process for the shared module
If the POC saved you time once and prevented drift once, it’s usually worth expanding to the rest of your networking layer. If it created constant friction (build time, debugging pain, awkward Swift API) even at small scope, pause and reassess.
Common mistakes
Most failed Kotlin Multiplatform POCs fail for one of two reasons: the scope is too big, or the integration experience is ignored until late. Here are the pitfalls that show up repeatedly—and the fixes that keep your POC small and valuable.
Mistake 1 — Sharing too much too early
If you start by sharing domain layers, persistence, DI, and complex architecture, you can’t tell what’s “KMP value” and what’s “new architecture churn.”
- Fix: share models + networking first. Add layers only after workflow feels good.
- Fix: cap the public API to one repository and a few DTOs in the POC.
Mistake 2 — Leaking platform assumptions into common code
Common code should not assume iOS UI threads, Android lifecycle, or platform-specific types.
- Fix: keep shared code “pure Kotlin” with platform adapters at the edges.
- Fix: expose stable types (DTOs, sealed results) rather than exceptions.
Mistake 3 — No “success criteria,” so the POC never ends
Teams keep adding features because no one agreed what “proves value” means.
- Fix: define 3–4 measurable signals (duplication removed, time saved, drift reduced).
- Fix: timebox the POC and stop when the criteria are met.
Mistake 4 — Treating iOS integration as an afterthought
A POC can look great in Kotlin and still fail if Swift integration is awkward or brittle.
- Fix: integrate into a real SwiftUI screen early (day 1–2), not at the end.
- Fix: add a thin Swift wrapper to make APIs feel native and testable.
Mistake 5 — Using the shared module as a dumping ground
“We’ll just put it in shared” quickly becomes “shared is messy,” and nobody wants to touch it.
- Fix: keep shared code organized:
api,models,errors,repo. - Fix: review the shared module like a library: public API, naming, documentation.
Mistake 6 — Ignoring build time and CI from the start
If local builds are slow or CI is flaky, even great shared code won’t be adopted.
- Fix: keep dependencies minimal in the POC; measure build time before/after.
- Fix: add a simple CI build step for both Android and iOS artifacts early.
A POC isn’t successful when it compiles. It’s successful when the next engineer says: “I’d use this again for the next endpoint.”
FAQ
What should the smallest Kotlin Multiplatform POC share first?
Share API models + networking + error mapping. That combo removes duplication quickly, reduces behavior drift, and keeps your UI fully native. It also keeps the POC scope small enough to finish without turning into a rewrite.
Should a first KMP POC share UI?
Usually no. UI-sharing introduces bigger decisions (design systems, navigation patterns, platform UX differences) that can derail a POC. Prove value with shared data plumbing first; consider shared UI later only if it clearly helps your product.
How do we avoid exposing “weird” generated APIs to Swift?
Add a tiny Swift adapter layer that wraps the shared module into clean Swift async functions and domain-friendly types. Wrap it once, then keep the rest of your iOS app idiomatic Swift/SwiftUI.
Is Kotlin Multiplatform worth it if we already have stable Android/iOS code?
It can be—especially if you frequently add endpoints, change models, or fight behavior mismatches. If your apps rarely change, or if platform teams are completely independent with minimal duplication, the ROI may be lower. The smallest POC is the right way to find out without committing.
What is the biggest risk in a Kotlin Multiplatform proof of concept?
Scope creep. The moment the POC tries to solve architecture, caching, and UI, you can’t tell whether KMP is helping or whether you’re just doing a large refactor. Keep the public surface small and focus on one feature slice.
How do we handle authentication in the smallest POC?
Keep it minimal: inject a token provider and add an Authorization header. Avoid refresh flows until the basic loop works.
Once the POC proves value, you can centralize token refresh and retry behavior in shared code safely.
Cheatsheet
Use this as a “scan and execute” checklist when you’re building or reviewing a Kotlin Multiplatform POC.
Smallest POC scope
- One feature slice (one screen)
- One endpoint + one response model
- Shared DTOs with safe defaults
- Shared networking + error mapping
- Native UI on both platforms
POC success criteria (pick 3–4)
- One change updates both Android and iOS
- Reduced behavior drift (parsing + errors match)
- Integration friction acceptable (Xcode/Gradle)
- Build/debug experience not a blocker
- Team would reuse the pattern for the next endpoint
Design rules that keep shared code healthy
| Rule | Do | Avoid |
|---|---|---|
| Public surface | Expose a small repository + DTOs | Exposing internal helpers and platform concepts |
| Error handling | Return typed errors | Throwing raw exceptions into UI layers |
| Scope | Timebox and measure | “While we’re here” refactors |
| Interop | Wrap awkward APIs in Swift once | Forcing the entire iOS app to conform to generated types |
When a POC gets hard, it’s usually because there are too many moving parts. Drop caching, DI frameworks, extra endpoints, and advanced flows until the core loop is clean: shared models + shared networking + native UI.
Wrap-up
The smallest Kotlin Multiplatform POC that proves value is not a demo app and not a rewrite. It’s a real slice of your product that removes duplication where it hurts most: shared models and shared networking. If your team can add an endpoint once and have both apps behave the same, you’ve already captured the core value.
Do this next (15–30 minutes)
- Pick the one endpoint for your POC
- Write your “POC success” criteria on a sticky note
- List the DTO fields + defaults + edge cases
- Decide how iOS will call the shared API (wrap if needed)
Do this next (after the POC)
- Add 2–3 more endpoints to confirm the pattern scales
- Introduce auth refresh/retry only if it’s truly needed
- Write simple tests around parsing + error mapping
- Document the shared module like an internal library
Start small. Prove value. Expand gradually. Keep UI native unless you have a strong reason not to. That’s how Kotlin Multiplatform becomes a pragmatic tool instead of a risky bet.
Quiz
Quick self-check (demo). This quiz is auto-generated for mobile / development / kotlin.