PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 2 min read

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

AspectKtorSpring Boot
ConfigurationExplicit DSLAuto-configuration + annotations
Startup time~200ms~2-5 seconds
Memory~30-50 MB~100-200 MB
CoroutinesNativeNeeds WebFlux or virtual threads
Learning curveSmall API surfaceLarge ecosystem to learn
EcosystemGrowingMassive
CommunitySmallerHuge
DIBYO (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.