Kotlin + Ktor — Building a REST API Without Spring
Build a REST API with Ktor 3 and Kotlin — routing, serialization, authentication, database access, and testing. A lightweight alternative to Spring Boot.
Spring Boot is the default choice for JVM backends. But it’s heavy — auto-configuration, classpath scanning, annotation processing, large dependency tree. For smaller services, microservices, or when you want full control, Ktor is a compelling alternative.
Ktor is built by JetBrains. It’s Kotlin-first, coroutine-native, and uses a DSL for everything. No annotations, no reflection, no magic. You configure what you need, nothing more.
Ktor 3.0 (released October 2024) moved to kotlinx-io and Kotlin 2.0. This post uses Ktor 3.
Project setup
Create a new project with Gradle:
build.gradle.kts:
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.serialization") version "2.1.0"
id("io.ktor.plugin") version "3.0.3"
}
application {
mainClass.set("com.example.ApplicationKt")
}
dependencies {
implementation("io.ktor:ktor-server-core")
implementation("io.ktor:ktor-server-netty")
implementation("io.ktor:ktor-server-content-negotiation")
implementation("io.ktor:ktor-serialization-kotlinx-json")
implementation("io.ktor:ktor-server-status-pages")
implementation("io.ktor:ktor-server-auth")
implementation("io.ktor:ktor-server-auth-jwt")
implementation("ch.qos.logback:logback-classic:1.5.15")
testImplementation("io.ktor:ktor-server-test-host")
testImplementation("io.ktor:ktor-client-content-negotiation")
testImplementation(kotlin("test"))
}
The Ktor Gradle plugin handles version alignment — you don’t need to specify versions for io.ktor dependencies.
Hello World
src/main/kotlin/com/example/Application.kt:
package com.example
import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
fun main() {
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}
fun Application.module() {
routing {
get("/") {
call.respondText("Hello, Ktor!")
}
}
}
Run it. Hit localhost:8080. Done. No annotation scanning, no auto-configuration, no startup banner.
JSON serialization
Install the ContentNegotiation plugin with kotlinx-serialization:
package com.example
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import kotlinx.serialization.json.Json
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
ignoreUnknownKeys = true
})
}
}
Now define a model and a route:
package com.example.models
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: String,
val name: String,
val email: String
)
package com.example.routes
import com.example.models.User
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
private val users = mutableListOf(
User("1", "Alice", "[email protected]"),
User("2", "Bob", "[email protected]")
)
fun Route.userRoutes() {
route("/api/users") {
get {
call.respond(users)
}
get("/{id}") {
val id = call.parameters["id"]
val user = users.find { it.id == id }
if (user != null) {
call.respond(user)
} else {
call.respond(HttpStatusCode.NotFound, mapOf("error" to "User not found"))
}
}
post {
val user = call.receive<User>()
users.add(user)
call.respond(HttpStatusCode.Created, user)
}
delete("/{id}") {
val id = call.parameters["id"]
val removed = users.removeAll { it.id == id }
if (removed) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(HttpStatusCode.NotFound, mapOf("error" to "User not found"))
}
}
}
}
Wire it up in the module:
fun Application.module() {
configureSerialization()
routing {
userRoutes()
}
}
GET /api/users returns JSON. POST /api/users with a JSON body creates a user. No @RestController, no @GetMapping — just functions.
Error handling with StatusPages
package com.example
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.response.respond
import kotlinx.serialization.Serializable
@Serializable
data class ErrorResponse(val error: String, val status: Int)
fun Application.configureErrorHandling() {
install(StatusPages) {
exception<IllegalArgumentException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(cause.message ?: "Bad request", 400)
)
}
exception<NotFoundException> { call, cause ->
call.respond(
HttpStatusCode.NotFound,
ErrorResponse(cause.message ?: "Not found", 404)
)
}
exception<Exception> { call, cause ->
call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse("Internal server error", 500)
)
}
}
}
class NotFoundException(message: String) : RuntimeException(message)
Now any unhandled exception gets a structured JSON error response. Routes can simply throw:
get("/{id}") {
val id = call.parameters["id"] ?: throw IllegalArgumentException("Missing id")
val user = users.find { it.id == id } ?: throw NotFoundException("User $id not found")
call.respond(user)
}
Request validation
Ktor doesn’t have built-in validation annotations like Spring. You write validation explicitly:
@Serializable
data class CreateUserRequest(
val name: String,
val email: String
)
fun CreateUserRequest.validate(): List<String> {
val errors = mutableListOf<String>()
if (name.isBlank()) errors.add("Name is required")
if (name.length > 100) errors.add("Name must be 100 characters or less")
if (!email.contains("@")) errors.add("Invalid email format")
return errors
}
post {
val request = call.receive<CreateUserRequest>()
val errors = request.validate()
if (errors.isNotEmpty()) {
call.respond(HttpStatusCode.BadRequest, mapOf("errors" to errors))
return@post
}
val user = User(
id = java.util.UUID.randomUUID().toString(),
name = request.name,
email = request.email
)
users.add(user)
call.respond(HttpStatusCode.Created, user)
}
It’s more explicit than annotation-based validation, but there’s no hidden behavior. What you write is what runs.
JWT authentication
package com.example
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.response.respond
fun Application.configureAuth() {
val secret = environment.config.property("jwt.secret").getString()
val issuer = environment.config.property("jwt.issuer").getString()
val audience = environment.config.property("jwt.audience").getString()
install(Authentication) {
jwt("auth-jwt") {
verifier(
JWT.require(Algorithm.HMAC256(secret))
.withAudience(audience)
.withIssuer(issuer)
.build()
)
validate { credential ->
if (credential.payload.audience.contains(audience)) {
JWTPrincipal(credential.payload)
} else {
null
}
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Invalid or missing token"))
}
}
}
}
Configuration in src/main/resources/application.conf:
jwt {
secret = "your-256-bit-secret"
issuer = "http://localhost:8080"
audience = "http://localhost:8080/api"
}
Protect routes:
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
fun Route.protectedRoutes() {
authenticate("auth-jwt") {
get("/api/me") {
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim("userId")?.asString()
call.respond(mapOf("userId" to userId))
}
}
}
Routes inside authenticate("auth-jwt") { } require a valid JWT. Routes outside it are public.
Database access with Exposed
Exposed is JetBrains’ SQL library for Kotlin. It pairs naturally with Ktor:
dependencies {
implementation("org.jetbrains.exposed:exposed-core:0.57.0")
implementation("org.jetbrains.exposed:exposed-dao:0.57.0")
implementation("org.jetbrains.exposed:exposed-jdbc:0.57.0")
implementation("com.h2database:h2:2.3.232")
}
Define the table:
package com.example.db
import org.jetbrains.exposed.sql.Table
object UsersTable : Table("users") {
val id = varchar("id", 36)
val name = varchar("name", 100)
val email = varchar("email", 255)
override val primaryKey = PrimaryKey(id)
}
Database setup:
package com.example.db
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
fun initDatabase() {
Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
transaction {
SchemaUtils.create(UsersTable)
}
}
Repository using Exposed:
package com.example.repository
import com.example.db.UsersTable
import com.example.models.User
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
class UserRepository {
fun getAll(): List<User> = transaction {
UsersTable.selectAll().map { it.toUser() }
}
fun getById(id: String): User? = transaction {
UsersTable.selectAll()
.where { UsersTable.id eq id }
.map { it.toUser() }
.singleOrNull()
}
fun create(user: User): User = transaction {
UsersTable.insert {
it[id] = user.id
it[name] = user.name
it[email] = user.email
}
user
}
private fun ResultRow.toUser() = User(
id = this[UsersTable.id],
name = this[UsersTable.name],
email = this[UsersTable.email]
)
}
Testing
Ktor has a built-in test engine. No HTTP server is started — requests are handled in-memory:
package com.example
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.testing.testApplication
import com.example.models.User
import kotlin.test.Test
import kotlin.test.assertEquals
class UserRoutesTest {
@Test
fun `GET users returns list`() = testApplication {
application { module() }
val client = createClient {
install(ContentNegotiation) { json() }
}
val response = client.get("/api/users")
assertEquals(HttpStatusCode.OK, response.status)
val users = response.body<List<User>>()
assertEquals(2, users.size)
}
@Test
fun `POST creates user and returns 201`() = testApplication {
application { module() }
val client = createClient {
install(ContentNegotiation) { json() }
}
val response = client.post("/api/users") {
contentType(ContentType.Application.Json)
setBody(User("3", "Charlie", "[email protected]"))
}
assertEquals(HttpStatusCode.Created, response.status)
val user = response.body<User>()
assertEquals("Charlie", user.name)
}
@Test
fun `GET unknown user returns 404`() = testApplication {
application { module() }
val response = client.get("/api/users/unknown")
assertEquals(HttpStatusCode.NotFound, response.status)
}
}
testApplication configures the full application pipeline — serialization, routing, error handling — without a network socket. Tests are fast and deterministic.
Full module wiring
package com.example
import com.example.db.initDatabase
import com.example.routes.userRoutes
import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.routing.routing
fun main() {
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}
fun Application.module() {
initDatabase()
configureSerialization()
configureErrorHandling()
configureAuth()
routing {
userRoutes()
protectedRoutes()
}
}
Each configure* function is an extension on Application. You can see exactly what’s installed. No classpath scanning, no conditional auto-configuration.
Ktor vs Spring Boot
| Aspect | Ktor | Spring Boot |
|---|---|---|
| Configuration | Explicit DSL | Auto-configuration + annotations |
| Startup time | ~200ms | ~2-5 seconds |
| Memory | ~30-50 MB | ~100-200 MB |
| Coroutines | Native | Needs WebFlux or virtual threads |
| Learning curve | Small API surface | Large ecosystem to learn |
| Ecosystem | Growing | Massive |
| Community | Smaller | Huge |
| DI | BYO (Koin, Kodein) | Built-in (Spring DI) |
Choose Ktor when: You want a lightweight service, you’re building microservices, you want full control, or you’re building a Kotlin-first backend.
Choose Spring Boot when: You need the massive ecosystem (Spring Security, Spring Data, Spring Cloud), you’re in a Java-heavy organization, or you need the maturity and community support.
Summary
Ktor gives you a Kotlin-native backend framework that’s lightweight, explicit, and coroutine-first. The DSL approach means no annotations and no magic — what you configure is what you get.
Start with the basics (routing + serialization), add plugins as needed (auth, status pages, CORS), and test everything with the built-in test engine. For small-to-medium services, Ktor is hard to beat.