MVP Factory
End-to-End Kotlin: Sharing Type-Safe API Contracts Between Ktor and Compose Multiplatform with Kotlinx.Serialization and Ktor Resources
ai startup development

End-to-End Kotlin: Sharing Type-Safe API Contracts Between Ktor and Compose Multiplatform with Kotlinx.Serialization and Ktor Resources

KW
Krystian Wiewiór · · 5 min read

API drift will ruin your month

The most dangerous bugs are the quiet ones. API drift between your backend and mobile clients is exactly that kind of quiet.

In a typical multi-team setup, here’s what happens: the backend engineer renames a field from userId to user_id. The Android client breaks at runtime. The iOS client breaks differently. Nobody catches it until QA, or worse, production. I’ve watched teams burn entire sprint cycles debugging serialization mismatches that should never have existed.

On a project with 47 API endpoints, we tracked integration bugs over six months:

MetricBefore (separate contracts)After (shared KMP module)
Serialization mismatch bugs/month8.30
API integration test count312187
Time to add new endpoint (avg)4.2 hours1.6 hours
Runtime API errors in staging23/month2/month
Build-time contract violations caught0~11/month

Those runtime errors didn’t disappear. They shifted left into compile-time failures, which is exactly where you want them.

The architecture

You create a shared KMP module (I call mine api-contract) that contains route definitions, data models, and error types.

project/
├── api-contract/       # Shared KMP module
│   └── src/commonMain/
│       ├── routes/     # Ktor Resources route definitions
│       ├── models/     # Request/Response DTOs
│       └── errors/     # Typed API errors
├── server/             # Ktor backend (depends on api-contract)
└── composeApp/         # CMP client (depends on api-contract)

Step 1: define routes with Ktor Resources

// 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 aren’t path strings. They’re type-safe, serializable route objects that both server and client understand at compile time.

Step 2: shared DTOs with validation baked in

// api-contract/src/commonMain/kotlin/models/UserModels.kt
@Serializable
data class CreateUserRequest(
    val email: String,
    val displayName: String,
) {
    fun validate(): List<String> = 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 validation logic lives in the shared module. The server calls validate() before processing. The client calls validate() before sending. Same rules, zero divergence.

Step 3: typed error contracts

// api-contract/src/commonMain/kotlin/errors/ApiError.kt
@Serializable
sealed class ApiError {
    @Serializable @SerialName("validation")
    data class Validation(val fields: Map<String, String>) : 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()
}

Step 4: server consumes the contract

// server/src/main/kotlin/Server.kt
fun Application.configureRoutes() {
    routing {
        get<Users.ById> { 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))
        }
    }
}

Step 5: client consumes the same contract

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

The client uses the same Users.ById resource class. The same UserResponse model. If the server changes the contract, the client won’t compile. That’s the whole point.

End-to-End Kotlin: Sharing Type-Safe API Contracts Between Ktor and Compose Multiplatform with Kotlinx.Serialization and Ktor Resources

What most teams get wrong

They share the models but keep routes as raw strings. That gets you half the safety. The real win is Ktor Resources making routes first-class typed objects. When you rename a path parameter or restructure a nested route, every consumer sees the break immediately.

The second mistake is skipping shared error types. Without ApiError as a sealed class in your shared module, clients end up parsing error JSON with brittle string matching. Sealed hierarchies give you exhaustive when blocks. The compiler forces you to handle every error case.

What to do with this

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

Use Ktor Resources for routes instead of raw strings. The type safety on path parameters and query parameters alone justifies the dependency. Combined with Kotlinx.Serialization, you get a fully compile-time-verified API surface.

Put validation in the shared module. Client-side and server-side validation should be the same function call. This kills the entire category of “the client allows it but the server rejects it” bugs that frustrate users and waste engineering time.

I know the upfront investment feels slow compared to copy-pasting models and moving on. But six months in, the team with shared contracts is shipping faster than the team that took shortcuts. The structure was never the slow part. The bugs were.

TAGS: kotlin, kmp, multiplatform, architecture, api


Share: Twitter LinkedIn