DEV Community

Vladyslav Diachuk
Vladyslav Diachuk

Posted on

Modern KMP (Part 1): The End of the "404 Not Found"2

How to use "Contract-First" development to guarantee your Android/iOS clients and Ktor Backend are always in perfect sync.

It’s 4:55 PM on a Friday. You just deployed a hotfix to the backend. You feel good. Five minutes later, Sentry alerts start screaming.

SerializationException: Field 'user_id' is required but missing.

Ah, right. You refactored the backend response model to use camelCase userId, but you forgot to update the Android Retrofit interface and the iOS Codable struct.

This is "The Drift." It is the silent killer of mobile agility.

A split graphic showing a Client expecting  raw `Shape: Square` endraw  and Server sending  raw `Shape: Circle` endraw . Representing the API Drift

In the traditional world, we fight The Drift with communication: Slack messages, Swagger/OpenAPI docs, and hope. But docs get outdated, and hope is not a strategy.

In the Kotlin Multiplatform (KMP) world, we can do better. We can fight The Drift with the Compiler.

In this 3-part series on Modern KMP Architecture, I’m going to share the setup I use to bridge the gap between Client and Server.

  • Part 1 (This Article): Full-Stack Type Safety (The "Contract-First" Pattern).

  • Part 2: Scalable Modularization (Solving the "God Module" build problem).

  • Part 3: The Anatomy of a Feature (Decoupled Navigation & UDF).

A Note on "Enterprise Grade" vs. "Experimental Magic": The modularization strategies I will cover in Part 2 and 3 are battle-tested patterns I’ve learned from working on large-scale enterprise apps. However, the Server-Side generation I am showing today is a proof-of-concept tool. It is magical, it works, but if you are building a banking backend, you might want to stick to manual routing while keeping the shared interface concept.

Let’s solve the biggest problem first: The Contract.

The "Code-First" Contract

Most KMP tutorials teach you how to share Data (e.g., the User data class). That's a good start, but it's not enough. If your Client thinks the endpoint is GET /user and your Server thinks it's GET /users, sharing the data class won't save you.

We need to share the Behavior.

In my architecture, the "Source of Truth" is not a YAML file or a Confluence page. It is a simple Kotlin Interface located in a shared :network:api module.

Diagram showing the  raw `:network:api` endraw  module sitting in the center, feeding into  raw `:client` endraw  (Android/iOS) and  raw `:server` endraw  (Ktor). The Interface is the heart.

// :network:api/src/commonMain/kotlin/UserApi.kt

interface UserApi {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") userId: Long): User

    @POST("users")
    suspend fun createUser(@Body user: User): User

    @NoAuth
    @GET("users/public")
    suspend fun getPublicInfo(): String

    @Multipart
    @POST("users/avatar")
    suspend fun uploadAvatar(@Part("avatar") content: List<PartData>): String
}

Enter fullscreen mode Exit fullscreen mode

💡 Mental Model: If you have used gRPC, this will feel familiar. We are effectively using Kotlin Interfaces as our .proto files. This gives us the strict contract safety of RPC, but keeps the simplicity, tooling, and inspectability of standard HTTP/JSON.

This looks like a standard Retrofit interface. But in this architecture, this file drives everything.

1. The Client Side (Ktorfit)

For the client (Android, iOS, Desktop), we use Ktorfit. It’s essentially "Retrofit for KMP."

It looks at that interface and generates the Ktor Client implementation for us.

// Generated Client Code (Simplified)
public class _UserApiImpl(private val client: HttpClient) : UserApi {
    override suspend fun getUser(userId: Long): User {
        return client.get("users/$userId").body()
    }
}

Enter fullscreen mode Exit fullscreen mode

This is standard practice. But now, let’s do something radical.

2. The Server Side (The Experiment)

Usually, Ktor server routing is written manually. This is where the drift happens. You type post("auth/login") manually, and if you make a typo, you get a 404.

To solve this, I wrote a custom KSP (Kotlin Symbol Processing) processor for the Server. It reads the exact same interface used by the client and generates the server routing entry points.

It even handles Context Propagation. One of the biggest challenges with shared interfaces is: "How do I get the HTTP Headers or the Auth Token inside my implementation if the interface function signature doesn't have a call parameter?"

My processor wraps the execution in a CallContext, allowing you to access the raw Ktor ApplicationCall and your typed JWT token from anywhere in your implementation.

// server/src/.../UserApiImpl.kt

// 1. You implement the shared interface
class UserApiImpl(private val userDao: UserDao) : UserApi {

    override suspend fun getUser(userId: Long): User {
        // 2. Need the raw call or token? Use the context wrapper.
        val context = getCallContext<UserToken>() 

        val token = context.jwtToken // Strongly typed Token object
        val call = context.call      // Raw RoutingCall (headers, cookies, IP)

        return userDao.getUserById(userId) ?: throw NotFoundException()
    }

    override suspend fun createUser(user: User): User {
        return userDao.insertUser(user)
    }
}

Enter fullscreen mode Exit fullscreen mode

The Magic Trick: Coroutine Context

You might be wondering: "How does getCallContext() find the call if I never passed it as an argument?"

If we added call: ApplicationCall to the interface methods, the Client would break (because the Client doesn't know what a Ktor Server ApplicationCall is).

The solution is Kotlin Coroutine Contexts.

Since Ktor handles every request inside a coroutine, my processor generates code that injects the data into the CoroutineContext scope just before your function runs.

// Generated by the Processor (Simplified)
post("auth/register") {
  val request = call.receive<RegisterRequest>()
  val principal = ... // Parsed from JWT or Unit if @NoAuth

  // The Trick: We inject the context into the Coroutine Scope
  withContext(CallContext(call, principal)) {
    call.respond(impl.register(request))
  }
}

Enter fullscreen mode Exit fullscreen mode

This allows getCallContext() to retrieve the data from the current scope, acting like a "Coroutine-Local" variable. This keeps your shared interface clean and platform-agnostic, while giving the server implementation full access to the HTTP context.

Now, your Server Application code is dumb. It just binds the implementation:

// server/src/Application.kt
routing {
    // The compiler forces me to provide an implementation of UserApi
    bindUserApi(UserApiImpl(userDao)) 
}

Enter fullscreen mode Exit fullscreen mode

The "Wow" Moment

Because both sides rely on the generated code from the same interface file, we achieve Full-Stack Type Safety.

Here is what happens if I decide to change createUser(@Body user: User) to createUser(@Body user: User, @Query("force") force: Boolean):

  1. I change the Kotlin Interface in :network:api.

  2. I hit "Build."

  3. The Client Fails: The Android codebase turns red because I’m passing the wrong arguments.

  4. The Server Fails: The Backend codebase turns red because the implementation class no longer overrides the interface correctly.

The build does not pass until both the Client and the Server agree on the new contract.

GIF showing the IDE Split Screen. Left side: Interface file being edited. Right side: Build Output window showing errors in both  raw `:composeApp` endraw  and  raw `:server` endraw  modules simultaneously.

The Reality Check (Trade-offs)

This sounds like magic, but every architectural decision has a cost.

1. You Own The Tooling This KSP processor is a cool project, but it is not a standard library maintained by JetBrains. It works for my template, but if Ktor changes its DSL drastically, or if you have complex routing needs (e.g., wildcards, regex paths) that the processor doesn't support, you will hit a wall.

  • Recommendation: For a startup or a hobby project? This is a superpower. For a massive enterprise legacy migration? Use the Shared Interface pattern, but maybe write the server-side routing manually to stay on the safe side.

2. The JWT Type Safety Gap There is one specific edge case I couldn't solve purely at compile-time. While the API Contract is fully typed, the Auth Token Type is not strictly enforced by the compiler. If you define your interface to require an AdminToken, but inside your implementation you ask for getCallContext<UserToken>(), the code will compile. However, at runtime, the CallContext will contain the wrong token type, leading to a crash or an error. This is a limitation of how contexts are propagated at runtime vs. generic erasure at compile time.

3. Strict Contracts You cannot "just code." You must define your data shape and interfaces before writing UI or server logic. This slows down initial prototyping but significantly speeds up long-term maintenance.

4. The "Lock-Step" Trap (Versioning) The compiler guarantees that your current App code matches your current Server code. It does not guarantee that the app installed on a user's phone (from three months ago) matches your new Server deployment.

  • The Rule: If you change an interface method signature, you break old clients.

  • The Fix: Never break; only append. If you need to change the logic for getUser, do not modify the existing function. Create getUserV2() in the interface, mark the old one as @Deprecated, and let the compiler guide you to migrate the client code while keeping the old endpoint alive for legacy users.

Seeing is Believing

I’ve open-sourced this entire setup in my template, ModernArchitecture. It includes:

  • The Shared :network module.

  • The Ktorfit setup for Clients.

  • The Custom KSP Processor for the Server (with Context & Auth support).

You can clone it, change an endpoint, and watch your project break (safely) in real-time.

Link to GitHub repo

What's Next?

We have a secure, type-safe contract. But as your app grows to 50, 60, or 100 features, you hit the next major KMP bottleneck: Build Times.

How do we organize a massive KMP project so that changing one line of code doesn’t force us to recompile the entire world?

In Part 2, I’ll switch gears to the Battle-Tested Architecture: The API/Impl Split Pattern and the Database Inversion technique that keeps our builds lightning fast.

Top comments (0)