DEV Community

Shubhr Modh
Shubhr Modh

Posted on

Building a Shared Networking Layer using ktor in KMP

Maintaining separate networking layers for Android and iOS often leads to code duplication and synchronisation headaches. By using Ktor, we can build a single, shared codebase for both the frontend networking and the backend API models.

In this article, I’ll share how I built a shared networking layer for FieldSync, a project that handles authentication through a single reusable codebase.

The Architecture: One Language, Two Ends
In this project, I implemented an authentication flow (Phone Login & OTP) where the Data Transfer Objects (DTOs) are shared between the Ktor Server and the KMP Mobile app.

  1. Project Overview
    The project consists of modules:
    • shared: Contains the Ktor HTTP Client and Request/Response models.
    • server: A Ktor-based backend handling OTP generation.
    • Android/iOS Apps: Consuming the shared repository.

  2. Dependency Setup
    First, we ensure our libs.versions.toml and build.gradle.kts are synced. Both the server and the shared module need kotlinx-serialization.

commonMain.dependencies {
    implementation(libs.kotlinx.coroutines.core)
    implementation(libs.ktor.client.core)
    implementation(libs.ktor.client.content.negotiation)
    implementation(libs.ktor.serialization.kotlinx.json)
    implementation(libs.kotlinx.serialization.json)
}
Enter fullscreen mode Exit fullscreen mode

libs.versions.toml:

ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
ktor-serverNetty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" }
ktor-serverTestHost = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
Enter fullscreen mode Exit fullscreen mode
  1. Backend Implementation (The ktor Server) The server runs on Netty. To ensure it is accessible to mobile emulators, we bind it to 0.0.0.0.
// server/.../Application.kt
fun main() {
    embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true)
}

fun Application.module() {
    install(ContentNegotiation) { json() }

    routing {
        get("/") { call.respond(mapOf("status" to "FieldSync backend running")) }
        authRoutes() // Routes for /send-otp and /verify-otp
    }
}
Enter fullscreen mode Exit fullscreen mode

The Developer “Hack” for OTP: Since third-party SMS gateways (like Twilio or MSG91) aren’t free, I implemented a console based flow for development:

  1. Server generates a random 6-digit code.
  2. Server prints the code to the IntelliJ Console.
  3. The developer reads the console and types the code into the emulator.

  4. The Shared Networking Layer
    The heart of the project is the AuthRepository in the shared module. This is where we handle the platform specific localhost differences.

class AuthRepository {
    private val client = HttpClient {
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true })
        }
    }

    // Solve the "Localhost" problem
    private val baseUrl: String
        get() = if (getPlatform().name.contains("Android")) {
            "http://10.0.2.2:8080" // Special alias for computer's localhost in Android
        } else {
            "http://localhost:8080" // Standard for iOS Simulator
        }

    suspend fun sendOtp(phone: String): Result<String> {
        return try {
            val response = client.post("$baseUrl/send-otp") {
                contentType(ContentType.Application.Json)
                setBody(SendOtpRequest(phone)) // Shared DTO
            }
            if (response.status == HttpStatusCode.OK) Result.success("Sent") 
            else Result.failure(Exception("Error: ${response.status}"))
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Challenges Faced While implementing this architecture, I encountered a few common issues: A. The “CLEARTEXT” Error Android blocks http traffic by default. To fix this for local development, I had to update the AndroidManifest.xml:
<application
android:usesCleartextTraffic=”true”>
</application>
Enter fullscreen mode Exit fullscreen mode

B. Emulator Networking
New KMP developers often struggle with localhost. I learned that 127.0.0.1 inside an Android emulator points to the emulator itself, not the machine running the ktor server. Using 10.0.2.2 is the bridge to the host machine.

  1. Benefits of This Approach Single Source of Truth: If the SendOtpRequest model changes, it updates for both the Server and the Apps simultaneously. Consistent Behavior: Error handling and JSON parsing logic are identical on iOS and Android. Faster Iteration: You can test your backend logic and UI flow without ever leaving the Kotlin ecosystem.

Conclusion
Building a shared networking layer with ktor isn’t just about writing less code, it’s about building a more reliable system. By centralising API communication, we reduce the surface area for bugs and ensure that our mobile platforms behave as twins rather than distant cousins.
What’s next? In the next phase, I plan to integrate Firebase Authentication to replace the console based OTP with real SMS delivery and add Token based persistence for user sessions.

Top comments (0)