DEV Community

Cover image for End-to-End Kotlin
SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

End-to-End Kotlin

---
title: "End-to-End Kotlin: Share Type-Safe API Contracts Across Server and Client"
published: true
description: "Build a shared KMP module that defines routes, DTOs, validation, and error types  consumed by both your Ktor server and Compose Multiplatform clients."
tags: kotlin, android, architecture, api
canonical_url: https://blog.mvpfactory.co/end-to-end-kotlin-share-type-safe-api-contracts-across-server-and-client
---

## What we are building

Let me show you a pattern I use in every project that has a Kotlin backend and Kotlin clients. We will create a shared Kotlin Multiplatform module that holds your entire API surface — routes, request/response models, validation rules, and error types. Your Ktor server and Compose Multiplatform app both depend on this module. If someone renames a field or changes a route, the project will not compile until every consumer is updated.

On my last project (47 endpoints, two mobile clients), this eliminated serialization mismatch bugs entirely and cut integration testing effort by about 40%.

## Prerequisites

- Kotlin 2.0+ with a KMP project structure
- Ktor 3.x (server and client)
- `ktor-resources` plugin (for type-safe routing)
- `kotlinx-serialization` plugin

## Step 1: Create the shared module

Set up a `api-contract` KMP module with `commonMain` source set. This is the single source of truth.

Enter fullscreen mode Exit fullscreen mode

project/
├── api-contract/ # Shared KMP module
│ └── src/commonMain/
│ ├── routes/
│ ├── models/
│ └── errors/
├── server/ # Ktor backend
└── composeApp/ # CMP client


## Step 2: Define routes as typed objects

Here is the minimal setup to get this working. Instead of raw path strings, use Ktor Resources:

Enter fullscreen mode Exit fullscreen mode


kotlin
// api-contract/src/commonMain/kotlin/routes/UserRoutes.kt
@Serializable
@resource("/users")
class Users {
@Serializable
@resource("/{id}")
data class ById(val parent: Users = Users(), val id: Long)

@Serializable
@Resource("/search")
data class Search(val parent: Users = Users(), val query: String, val limit: Int = 20)
Enter fullscreen mode Exit fullscreen mode

}


These are compile-time-verified route objects. Both server and client understand them identically.

## Step 3: Share DTOs with validation baked in

Enter fullscreen mode Exit fullscreen mode


kotlin
// api-contract/src/commonMain/kotlin/models/UserModels.kt
@Serializable
data class CreateUserRequest(
val email: String,
val displayName: String,
) {
fun validate(): List = buildList {
if (!email.contains("@")) add("Invalid email format")
if (displayName.length !in 2..50) add("Display name must be 2-50 characters")
}
}

@Serializable
data class UserResponse(
val id: Long,
val email: String,
val displayName: String,
val createdAt: Instant,
)


The server calls `validate()` before processing. The client calls `validate()` before sending. Same function, zero divergence.

## Step 4: Define typed error contracts

Enter fullscreen mode Exit fullscreen mode


kotlin
// api-contract/src/commonMain/kotlin/errors/ApiError.kt
@Serializable
sealed class ApiError {
@Serializable @SerialName("validation")
data class Validation(val fields: Map) : ApiError()

@Serializable @SerialName("not_found")
data class NotFound(val resource: String, val id: String) : ApiError()

@Serializable @SerialName("unauthorized")
data class Unauthorized(val reason: String = "Invalid credentials") : ApiError()
Enter fullscreen mode Exit fullscreen mode

}


Sealed classes give you exhaustive `when` blocks. The compiler forces you to handle every error case.

## Step 5: Wire up server and client

The server consumes the contract directly:

Enter fullscreen mode Exit fullscreen mode


kotlin
// server/src/main/kotlin/Server.kt
fun Application.configureRoutes() {
routing {
get { route ->
val user = userService.findById(route.id)
?: return@get call.respond(HttpStatusCode.NotFound,
ApiError.NotFound("user", route.id.toString()))
call.respond(UserResponse(user.id, user.email, user.displayName, user.createdAt))
}
}
}


The client uses the exact same types:

Enter fullscreen mode Exit fullscreen mode


kotlin
// composeApp/src/commonMain/kotlin/UserRepository.kt
class UserRepository(private val client: HttpClient) {
suspend fun getUser(id: Long): Result = runCatching {
client.get(Users.ById(id = id)).body()
}
}


Same `Users.ById`. Same `UserResponse`. If the contract changes, both sides break at compile time. That is the whole point.

## Gotchas

**Sharing models but keeping routes as raw strings.** This is the mistake I see most often. You get half the safety. The real win is Ktor Resources making routes first-class typed objects — rename a path parameter, and every consumer sees the break immediately.

**Skipping shared error types.** Without `ApiError` as a sealed class in your shared module, clients end up parsing error JSON with brittle string matching. The docs do not mention this, but sealed hierarchies are what make error handling maintainable across platforms.

**Forgetting `@SerialName` on sealed subclasses.** Kotlinx.Serialization will use the fully qualified class name as the discriminator by default. That produces ugly, fragile JSON. Always set explicit `@SerialName` values.

**Putting platform-specific types in `commonMain`.** Use `kotlinx-datetime` `Instant` instead of `java.time`. The moment you leak a JVM type into the shared module, your iOS target will not compile.

## Wrapping up

Start with your highest-traffic endpoints. Move route definitions, DTOs, and error types into a `commonMain` module. You will catch your first prevented bug within a week.

Here is the gotcha that will save you hours: the upfront investment feels slow compared to copy-pasting models and moving on. But six months in, the team with shared contracts ships faster. The structure was never the slow part. The bugs were.

**Resources:**
- [Ktor Resources plugin docs](https://ktor.io/docs/server-resources.html)
- [Kotlinx.Serialization guide](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/serialization-guide.md)
- [Kotlin Multiplatform project setup](https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-getting-started.html)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)