---
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.
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:
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)
}
These are compile-time-verified route objects. Both server and client understand them identically.
## Step 3: Share DTOs with validation baked in
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
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()
}
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:
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:
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)
Top comments (0)