Bereke Android Meetup · GDG Almaty · 28 May 2026

KMP without
rewriting the app.

Max Kachinkin · Tech & Team Lead, Drinkit Mobile

About me

Max Kachinkin

Max Kachinkin

Tech lead & Team lead · Drinkit Mobile

// Dodo Engineering

  • Android Tech Lead — 15 years of experience
  • Program Director, Podlodka Android Crew
  • Course Lead, Android Developer Professional @ Otus
  • Telegram channel Mobile Fiction · @mobilefiction
  • Writes on Habr / Medium

Dodo Engineering

By the numbers.

63k

employees

365

in tech

26

countries

What Drinkit is

Drinkit app icon

Drinkit.

Specialty coffee from Dodo Brands.

// When I say "the app", this is the one.

Drinkit Android home

Today

  • A real story, not a sales pitch.
  • 2 production apps · iOS + Android · legacy code.
  • How we started using KMP without rewriting anything.
  • Honest results — what worked, what didn't.
Best question wins a prize. 🎁

// part 01

Do you also have
two different apps?

Side by side · home

iOS
Drinkit iOS home
Android
Drinkit Android home

"Spot the difference."

Side by side · menu

iOS
Drinkit iOS menu
Android
Drinkit Android menu

Side by side · customisation

iOS
Drinkit iOS customisation
Android
Drinkit Android customisation

Side by side · cart

iOS
Drinkit iOS cart
Android
Drinkit Android cart
Same outside.
Two different worlds inside.

Two architectures

Android per-feature module stack vs iOS layered architecture

// part 02

Why our client
got thick.

Native feel · shared-element transitions

  • Card → detail morphs natively.
  • Physics-driven, finger-responsive.
  • Hard to do with thin / BDUI clients.
  • Part of the brand.
Thick client.
On purpose.
…and this is where
it starts to hurt.

// part 03

We write the same thing
twice.

Almost the same.
Not exactly the same.

Customer feedback

customer · #drinkit-feedback

today, 14:32

«А почему у моей подруги комбо доступно,
а у меня на айфоне нет? Что за несправедливость?!»

Is this a bug?
No source of truth.

A real combo rule — implemented twice.

// android — Kotlin (live)

fun pickSlotProduct(slot: Slot, alreadyPicked: Set<String>): String {
    val default = slot.products.firstOrNull { it.id == slot.defaultProductId }
    if (default != null && !default.isInStop && default.id !in alreadyPicked) return default.id
    val fresh = slot.products.firstOrNull { !it.isInStop && it.id !in alreadyPicked }
    if (fresh != null) return fresh.id
    return slot.products.firstOrNull { !it.isInStop }?.id ?: slot.products.first().id
}

// ios — Swift (also live)

func pickSlotProduct(slot: Slot, alreadyPicked: Set<String>) -> String {
    if let d = slot.products.first(where: { $0.id == slot.defaultProductId }),
       !d.isInStop {
        return d.id
    }
    if let fresh = slot.products.first(where: { !$0.isInStop }) {
        return fresh.id
    }
    return slot.products.first!.id
}

A real combo rule — spot the bug.

// android — Kotlin (live)

fun pickSlotProduct(slot: Slot, alreadyPicked: Set<String>): String {
    val default = slot.products.firstOrNull { it.id == slot.defaultProductId }
    if (default != null && !default.isInStop && default.id !in alreadyPicked) return default.id
    val fresh = slot.products.firstOrNull { !it.isInStop && it.id !in alreadyPicked }
    if (fresh != null) return fresh.id
    return slot.products.firstOrNull { !it.isInStop }?.id ?: slot.products.first().id
}

// ios — Swift (also live)missing both dedup checks ↓

func pickSlotProduct(slot: Slot, alreadyPicked: Set<String>) -> String {
    if let d = slot.products.first(where: { $0.id == slot.defaultProductId }),
       !d.isInStop /* no `!alreadyPicked.contains(d.id)` */ {
        return d.id
    }
    if let fresh = slot.products.first(where: { !$0.isInStop /* no dedup */ }) {
        return fresh.id
    }
    return slot.products.first!.id
}

What we tried first

  • Shared product specs.
  • Shared decision docs.
  • A "single source of truth" doc folder.
  • Updated religiously last year.
It didn't save us.

Why docs aren't enough

  • Docs drift from code.
  • Edge cases live in code, not in prose.
  • No CI for "the doc and the code agree".
  • Two engineers ≠ one mental model.
There must be a
better way…

// part 04

KMP —
just start, right?

Every tutorial makes it look easy

kotlinlang.org / getting-started

Kotlin Multiplatform · Hello, world

Write once, run on Android & iOS. Share business logic, keep UI native.

// commonMain
fun greet() = "Hello, multiplatform!"

Step 1 · New project  ·  Step 2 · Add shared module  ·  Step 3 · Done.

Greenfield · the dream

Greenfield KMP layout: androidApp (Compose) and iosApp (SwiftUI) both depending on a shared KMP module with androidMain, iosMain, and commonMain
We are not greenfield.

Same product. Different geometry.

Android per-feature modules vs iOS layered architecture
Network · auto-generated.

Android · Retrofit + codegen from OpenAPI

android-app-sampels/aisuggestions/domain-impl/.../DrinkitLabApi.kt

interface DrinkitLabApi {
    @GET("api/v1/GetDrinkitLab")
    suspend fun getDrinkitLab(
        @Query("countryId") countryId: Int,
        @Query("unitId")   unitId: String,
    ): DrinkitLabDto
}

Source of truth: generated Kotlin · committed to repo.

iOS · Apple's OpenAPI Generator

ios-app-samples/DrinkitLab-domain/DrinkitLabService.swift

func getDrinkitLab(unitId: String)
    async throws -> DTO.DrinkitLab_DrinkitLabDto
{
    try await makeNetworkRequest {
        try await api.authorized.getApiV1GetDrinkitLab(
            query: .init(unitId: unitId)
        )
    } mapResponse: { response in
        try response.ok.body.json
    }
}

Source of truth: JSON spec · Swift regenerated on every build.

Different types. Different middleware. Different cancellation model.

Different source of truth

// android

Generated Kotlin is the contract.

  • Committed to the repo.
  • Diff-able.
  • Code-reviewed.

// ios

JSON is the contract.

  • Swift built fresh each time.
  • No DTO in the repo.
  • Generator output is ephemeral.
Cache & offline —
different too.
Where do you start?

Option 1 — data + domain together

Highlighted Android domain-impl/data and iOS domain/data — different shapes, can't merge

Option 2 — data only

Highlighted Android data per feature and iOS generated data — still different shapes

Option 3 — one full feature

One full Android feature stack lifted into KMP — pulls the data layer with it

Option 4 — bend iOS to Android style

iOS architecture replaced with per-feature modules — year of churn, no user value

Option 5 — take infra (logs, analytics, …)

Highlighted cross-cutting infra (logs, analytics, etc.) — designed differently on each platform

// part 05

Start with
what's already shared.

Remember this code? KMP fixes this.

// android — Kotlin (live)

fun pickSlotProduct(slot: Slot, alreadyPicked: Set<String>): String {
    val default = slot.products.firstOrNull { it.id == slot.defaultProductId }
    if (default != null && !default.isInStop && default.id !in alreadyPicked) return default.id
    val fresh = slot.products.firstOrNull { !it.isInStop && it.id !in alreadyPicked }
    if (fresh != null) return fresh.id
    return slot.products.firstOrNull { !it.isInStop }?.id ?: slot.products.first().id
}

// ios — Swift (also live)missing both dedup checks ↓

func pickSlotProduct(slot: Slot, alreadyPicked: Set<String>) -> String {
    if let d = slot.products.first(where: { $0.id == slot.defaultProductId }),
       !d.isInStop /* no `!alreadyPicked.contains(d.id)` */ {
        return d.id
    }
    if let fresh = slot.products.first(where: { !$0.isInStop /* no dedup */ }) {
        return fresh.id
    }
    return slot.products.first!.id
}

Two architectures. One pick.

Android per-feature modules vs iOS layered architecture

The one slice we can lift

Same module graph with one Android feature's domain-impl and iOS domain highlighted as shareable — green check between them
What hurts? Domain logic. That's what we share.

Pilot · Combo builder

// real feature

Combo builder.

Pick a drink. Pick a food.
Skip what's out of stock.
Honour the cross-slot dedup rule.
Compute the right price.

Why combo

  • Real client-side logic — rules, picking, pricing.
  • Identical behaviour required on both platforms.
  • Already a divergence source — we'd written it twice.
  • Pure function: no IO, no state, no platform deps.

What lives in KMP — just the pure function

Android and iOS module graphs both arrowing into a single shared KMP domain box

The whole shared surface

combo/src/commonMain/.../ComboResolver.kt

// Inputs in. Result out. No platform deps.
fun resolveCombo(
    template: ComboTemplate,
    menu: Menu,
): ComboResolution

One pure function. Deterministic. No IO.

Wiring — one line on each platform

// android

drinkit-android/feature/combo/build.gradle.kts

dependencies {
    implementation("io.dodobrands.kmp:combo:1.0.0")
}

…call site

val result = resolveCombo(template, menu)

// ios

drinkit-ios/Package.swift

.package(name: "DodoKMP", path: "../kmp")

…call site

let result = ComboResolverKt.resolveCombo(template: template, menu: menu)
Inputs in. Result out. No platform deps.

// part 06

When the feature
needs more.

A stateful feature · DrinkitLab

DrinkitLab — prompt input
DrinkitLab — generating
DrinkitLab — result drink

// prompt → generating → result

What this one needs from the world

  • Talks to a backend (an agent / LLM proxy).
  • Reads the menu (the AI needs to know what drinks exist).
  • Reads prompts from remote config.
  • Same logic on both platforms — but now there's glue.
DrinkitLab — prompt input
Same idea. More glue.

We can't just lift data into KMP

Android and iOS data layers arrowed at a shared KMP data box, blocked by a red X

It's not a pure function any more

  • The network — to fetch what the feature needs.
  • The menu — what drinks exist.
  • Stop-lists — what's available right now.
  • User preferences — country, language, prompts.
  • Same logic both platforms — but now there's glue.
Dependency inversion.
// baby steps.

Network — invert it. Provide an interface.

Shared KMP module exposes interface boxes; each app's data layer implements them

Domain — same trick. Data providers.

KMP centre exposes two interface rows: data providers (menu/stop-list) and data (HTTP); each app fulfils both

// part 07

The HTTP transport
bridge.

The constraint

  • Android — OkHttp + Retrofit + ~10 interceptors.
  • iOS — OpenAPI Generator + middleware chain.
  • Android commits the generated Kotlin DTOs / HTTP clients. iOS regenerates Swift on every build — nothing in the repo.
  • Both: auth, URL rewriting, captcha, metrics, logs.
  • Replacing them is a year of work.
Ktor on top.
Custom engine
underneath.

HttpTransport · the entire bridge

core/src/commonMain/.../transport/HttpTransport.kt

interface HttpTransport {
    fun execute(
        request:   TransportRequest,
        onSuccess: (TransportResponse) -> Unit,
        onFailure: (Throwable) -> Unit,
    ): Cancellable

    companion object {
        const val PLACEHOLDER_BASE_URL: String =
            "https://ai-proxy.drinkit.internal"
    }
}

fun interface Cancellable { fun cancel() }

TransportRequest · just bytes

core/src/commonMain/.../transport/TransportRequest.kt

data class TransportRequest(
    val path:    String,                       // starts with "/"
    val query:   String?,                      // already percent-encoded
    val method:  String,                       // "GET", "POST", ...
    val headers: Map<String, List<String>>,
    val body:    ByteArray?,
)

No URL. The host owns where it goes.

Ktor on top · HttpTransport at the bottom

Ktor HttpClient → TransportEngine → HttpTransport with Android (OkHttp) and iOS (URLSession) plugging in from the sides

Android · OkHttpTransport · uses your existing client

core/src/androidMain/.../transport/OkHttpTransport.kt

class OkHttpTransport(
    private val client: OkHttpClient,
) : HttpTransport {
    override fun execute(
        request: TransportRequest,
        onSuccess: (TransportResponse) -> Unit,
        onFailure: (Throwable) -> Unit,
    ): Cancellable {
        val call = client.newCall(buildOkRequest(request))
        call.enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) = onFailure(e)
            override fun onResponse(call: Call, response: Response) {
                response.use { r ->
                    onSuccess(TransportResponse(r.code, r.headers.toMultimap(), r.body?.bytes() ?: ByteArray(0)))
                }
            }
        })
        return Cancellable { call.cancel() }
    }
}

Android · implement HttpTransport

core/src/androidMain/.../transport/OkHttpTransport.kt

class OkHttpTransport(
    private val client: OkHttpClient,
) : HttpTransport {
    override fun execute(
        request: TransportRequest,
        onSuccess: (TransportResponse) -> Unit,
        onFailure: (Throwable) -> Unit,
    ): Cancellable {
        val call = client.newCall(buildOkRequest(request))
        call.enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) = onFailure(e)
            override fun onResponse(call: Call, response: Response) {
                response.use { r ->
                    onSuccess(TransportResponse(r.code, r.headers.toMultimap(), r.body?.bytes() ?: ByteArray(0)))
                }
            }
        })
        return Cancellable { call.cancel() }
    }
}

Android · override execute

core/src/androidMain/.../transport/OkHttpTransport.kt

class OkHttpTransport(
    private val client: OkHttpClient,
) : HttpTransport {
    override fun execute(
        request: TransportRequest,
        onSuccess: (TransportResponse) -> Unit,
        onFailure: (Throwable) -> Unit,
    ): Cancellable {
        val call = client.newCall(buildOkRequest(request))
        call.enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) = onFailure(e)
            override fun onResponse(call: Call, response: Response) {
                response.use { r ->
                    onSuccess(TransportResponse(r.code, r.headers.toMultimap(), r.body?.bytes() ?: ByteArray(0)))
                }
            }
        })
        return Cancellable { call.cancel() }
    }
}

Android · hand off to your OkHttp client

core/src/androidMain/.../transport/OkHttpTransport.kt

class OkHttpTransport(
    private val client: OkHttpClient,
) : HttpTransport {
    override fun execute(
        request: TransportRequest,
        onSuccess: (TransportResponse) -> Unit,
        onFailure: (Throwable) -> Unit,
    ): Cancellable {
        val call = client.newCall(buildOkRequest(request))
        call.enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) = onFailure(e)
            override fun onResponse(call: Call, response: Response) {
                response.use { r ->
                    onSuccess(TransportResponse(r.code, r.headers.toMultimap(), r.body?.bytes() ?: ByteArray(0)))
                }
            }
        })
        return Cancellable { call.cancel() }
    }
}

iOS · SampleHttpTransport · same interface, Swift

sampleIos/SampleIos/Network/SampleHttpTransport.swift

final class SampleHttpTransport: HttpTransport {
    func execute(
        request: TransportRequest,
        onSuccess: @escaping (TransportResponse) -> Void,
        onFailure: @escaping (KotlinThrowable) -> Void
    ) -> any Cancellable {
        let task = Task {
            // build URLRequest from baseURL + request.path + request.query
            let (data, response) = try await URLSession.shared.data(for: urlRequest)
            guard let http = response as? HTTPURLResponse else { onFailure(...); return }
            onSuccess(TransportResponse(
                statusCode: Int32(http.statusCode),
                headers:    parsedHeaders,
                body:       data.toKotlinByteArray()
            ))
        }
        return SampleCancellableTask(task: task)
    }
}

iOS · implement HttpTransport in Swift

sampleIos/SampleIos/Network/SampleHttpTransport.swift

final class SampleHttpTransport: HttpTransport {
    func execute(
        request: TransportRequest,
        onSuccess: @escaping (TransportResponse) -> Void,
        onFailure: @escaping (KotlinThrowable) -> Void
    ) -> any Cancellable {
        let task = Task {
            // build URLRequest from baseURL + request.path + request.query
            let (data, response) = try await URLSession.shared.data(for: urlRequest)
            guard let http = response as? HTTPURLResponse else { onFailure(...); return }
            onSuccess(TransportResponse(
                statusCode: Int32(http.statusCode),
                headers:    parsedHeaders,
                body:       data.toKotlinByteArray()
            ))
        }
        return SampleCancellableTask(task: task)
    }
}

iOS · the same execute signature

sampleIos/SampleIos/Network/SampleHttpTransport.swift

final class SampleHttpTransport: HttpTransport {
    func execute(
        request: TransportRequest,
        onSuccess: @escaping (TransportResponse) -> Void,
        onFailure: @escaping (KotlinThrowable) -> Void
    ) -> any Cancellable {
        let task = Task {
            // build URLRequest from baseURL + request.path + request.query
            let (data, response) = try await URLSession.shared.data(for: urlRequest)
            guard let http = response as? HTTPURLResponse else { onFailure(...); return }
            onSuccess(TransportResponse(
                statusCode: Int32(http.statusCode),
                headers:    parsedHeaders,
                body:       data.toKotlinByteArray()
            ))
        }
        return SampleCancellableTask(task: task)
    }
}

iOS · hand off to URLSession

sampleIos/SampleIos/Network/SampleHttpTransport.swift

final class SampleHttpTransport: HttpTransport {
    func execute(
        request: TransportRequest,
        onSuccess: @escaping (TransportResponse) -> Void,
        onFailure: @escaping (KotlinThrowable) -> Void
    ) -> any Cancellable {
        let task = Task {
            // build URLRequest from baseURL + request.path + request.query
            let (data, response) = try await URLSession.shared.data(for: urlRequest)
            guard let http = response as? HTTPURLResponse else { onFailure(...); return }
            onSuccess(TransportResponse(
                statusCode: Int32(http.statusCode),
                headers:    parsedHeaders,
                body:       data.toKotlinByteArray()
            ))
        }
        return SampleCancellableTask(task: task)
    }
}
Network — solved.
Same pattern
for everything else.

// part 08

How we ship it.

How do we share the KMP code?

  • Monorepo — apps and shared code in one repo.
  • Copy-paste — drop the shared file into each app.
  • Library — publish artifacts, depend on them.
Library.

Different shape on each platform

Android consumes per-module Maven artifacts; iOS consumes a single umbrella xcframework
CI ships it.

// part 09

Getting iOS engineers
to write Kotlin.

"Getting iOS engineers to…"
"Getting iOS engineers to…" // wrong frame
Forcing it = resentment. It feels unfair.
Tactical:
find an ally on iOS.
But we went bigger.

A western big-tech trend

  • Engineers stop being "iOS" or "Android".
  • They become "Mobile Engineers".
  • One human · two platforms.
  • AI tooling makes this realistic now.
Everyone is a
Mobile Engineer.

Why it's win-win

// company

One brain → two platforms

One mental model. One context. No cross-team handoffs. Better consistency than two engineers running in parallel.

// engineer

Growth · range · stronger CV

Your skill range doubles. You become more valuable — especially if you ever want to interview at a western big-tech.

Resistance to KMP
dropped.
It became
"yes, why not".

// part 10

Results &
what's next.

Shipped — in production today

  • 2 features running in both apps — DrinkitLab + a combo-logic feature.
  • HTTP transport bridge + glue pattern in production use.
  • CI build & release pipeline — artifacts published by tag, both apps consume.
  • Unit-test coverage on the shared code — meaningful, in CI.
  • iOS now has tests on this surface for the first time — Android already did.

App size impact

// android

+1 MB

// ios

+4 MB

We were ready for worse.

Team feedback · Android

"Great."

  • Familiar language.
  • Familiar tools.
  • Life is good.

Team feedback · iOS

"Actually great."

  • Some pain on the bridge — expected.
  • Now proactively proposing more KMP.
  • (That's when I knew this was working.)
TTM hasn't dropped —
yet.

What's next

  • Move from "feature" → "whole module".
  • Pilot: small feature with simple persistence + network.
  • Baby steps.
~2 of ~35 features.
// honest count
But the door is open.
If we could,
you can.
If you take one thing
from this talk…

The advice

  • Stop asking "what can we move?" — every layer drags its dependencies; you end up rewriting everything.
  • Ask "what do we write twice, today?" — that's the answer.
  • For Drinkit it was domain logic. For you, it might be something else.
Thank you.

Thank you. Questions?

QR code to slides + sample KMP repo
Max Kachinkin portrait

Max Kachinkin

Dodo Engineering · Drinkit Mobile

  • telegram   @maxkachinkin
  • channel   @mobilefiction · Mobile Fiction
  • linkedin   /maxkachinkin
  • twitter   /makzimi
← → navigate · O overview · F fullscreen · G go to · home/end jump