Skip to content
Go back

NetFlow Part 1: Why I Took the Leap from Android-Only to Kotlin Multiplatform

by KMP Bits

KMP Bits Cover

Originally published on Medium


Introduction

Networking in Android always feels routine. Fetch from an API. Store in Room. Wire it up with Flow. Repeat.

I got tired of it.

NetFlow was my answer.

Originally, NetFlow started as a simple Android library with a clean, fluent DSL. Multiplatform wasn’t even on my radar. But after seeing how much code sharing helps in real projects, and how much duplicated logic I was writing for iOS, curiosity took over. Maybe a touch of stubbornness, too.

Before I dove in, I tried rewriting the library in Swift using URLSession. It worked, but something was missing. The code lacked cohesion, and maintenance was hard. The Swift DSL just felt like “Kotlin with an accent” — not idiomatic or satisfying.

That’s when it clicked: I didn’t want to duplicate. I wanted to share.

Kotlin Multiplatform gave me exactly that: native networking on each platform, with unified business logic above.

In this series, I’ll walk you through why I built NetFlow, what made me embrace KMP, and the technical decisions along the way.


Why I built NetFlow

NetFlow started as a library called Communication, designed for Android. It simplified network requests and local data updates. When I started building with Kotlin Multiplatform, I wanted more than an Android-only solution.

I chose OkHttp over Retrofit for a few reasons. It’s a lower-level HTTP client, so there’s less abstraction overhead on complex or frequent network calls. It gives more direct control over interceptors, caching, and connection management. And when I started building this, Ktor wasn’t stable or mature enough to be a real production option.

By building on OkHttp directly, I kept the networking layer lean. On iOS, the same logic sits above URLSession. The platforms use their own native clients; the shared code lives above.


The pain points NetFlow solves

As an Android developer, I kept writing the same repository logic over and over. Managing Result wrappers manually. Syncing network updates with Room by hand. NetFlow wraps all of that into a single declarative API:

val usersFlow = client.call {
    path = "/users"
    method = HttpMethod.Get
}.responseFlow<List<User>> {

    onNetworkSuccess { users ->
        userDao.insertAll(users)
    }

    local({
        observe {
            userDao.getAllUsers()
        }
    }
}

The KMP version builds on the same ideas but adds iOS support, while keeping the same API.


Why Kotlin Multiplatform

Once Communication was stable for Android, I started wondering whether the same logic could run on iOS.

I had actually tried for several months to get this architecture working with KMP, but without success. There were too many moving parts: networking, serialization, platform dependencies, shared logic structure.

The idea stuck. I was already working on KMP projects, and something became obvious: Communication was a good candidate for multiplatform, not just for reuse, but because iOS apps face the same boilerplate problems. I was already writing duplicate logic on both platforms anyway.

The goal was to keep it native per platform: OkHttp on Android, URLSession on iOS, with shared logic on top. That’s exactly how it works now.

Migration wasn’t all smooth sailing. Details in Part 2. But KMP let me keep what I liked about the library while sharing the core and respecting each platform’s strengths.


What KMP actually means

When I first heard about Kotlin Multiplatform, I assumed it meant “share code between Android and iOS.” That’s technically true, but it undersells how different the approach is.

You write your core logic once in commonMain: networking, business rules, state management. Platform APIs stay native. Kotlin code on iOS calls Objective-C and Swift APIs directly, with no bridge or wrapper. On Android, it calls Java APIs the same way. There’s no engine sitting between your code and the platform.

This is where KMP differs from Flutter or React Native. Those frameworks wrap the platform behind their own runtime. With KMP, the shared code compiles to native. For NetFlow, that meant keeping OkHttp on Android and URLSession on iOS, with the higher-level networking logic shared between them.


Basic setup

Add NetFlow to your build.gradle.kts:

implementation("io.github.kmpbits:netflow-core:<latest_version>")

Initialize the client:

val client = netFlowClient {
    baseUrl = "https://api.example.com"
    header(Header(HttpHeader.CONTENT_TYPE), "application/json")
}

Traditional approach vs NetFlow

Here’s what “fetch all users and keep the UI updated” looks like with Ktor and Room:

suspend fun fetchUsers(): Result<List<UserDto>> {
    return try {
        val response = client.get("https://api.example.com/users")
        val users = json.decodeFromString<List<UserDto>>(response.bodyAsText())
        userDao.insertAll(users)
        Result.success(users)
    } catch (e: Exception) {
        Result.failure(e)
    }
}

fun observeUsers(): Flow<List<User>> {
    return userDao.getAllUsers().map { it.map { entity -> entity.toModel() } }
}

In the ViewModel, you launch one coroutine to fetch and another to observe.

With NetFlow:

val usersFlow = client.call {
  path = "/users"
  method = HttpMethod.Get
}.responseFlow<List<User>> {

  onNetworkSuccess { usersDto ->
    userDao.insertAll(users.map(UserDto::toEntity))
  }

  local({
    observe {
      userDao.getAllUsers()
    }
  }, transform = { it.map(UserEntity::ToDto) })
}.map {
  it.map { it.map(UserDto::toModel) }
}

And in the ViewModel:

viewModelScope.launch {
    usersFlow.collectLatest { result ->
        when(result) {
            is ResultState.Loading -> showLoading()
            is ResultState.Success -> showUsers(result.data)
            is ResultState.Error -> showError(result.exception.message)
            ResultState.Empty -> showEmptyState()
        }
    }
}

One flow handles fetch, storage, observation, and full UI state. By the time the result reaches ResultState.Success, the items are already in the database.


Preparing the migration

Once I decided to make NetFlow multiplatform, the immediate work was modular: separate the platform-specific HTTP logic (OkHttp vs URLSession), replace Android-only types in commonMain, and keep the DSL as close to the original as possible.

In Part 2, I’ll cover how I handled those technical challenges, including how I abstracted the HTTP layer to behave consistently on both platforms.

It was smoother than I expected, but there were some sharp corners.


What’s next

Part 2 dives into the real technical work: abstracting the HTTP logic, working with OkHttp and URLSession in parallel, and keeping the developer experience consistent across Android and iOS.

The source is on GitHub if you want to look ahead.


Share this post on:

Comments

0 / 250

Loading comments...


Previous Post
🧊 How Talking to Myself, Vanilla PHP, and an Ice Cream Led Me to Kotlin Multiplatform
Next Post
🚨 iOS 26’s Liquid Glass Is a Game-Changer for Kotlin Multiplatform — And a Wake-Up Call for Flutter