Building a REST service with Ktor

Project setup, routing, content negotiation, plugins, authentication, Exposed database access, configuration, logging and graceful shutdown.

Application and routing

// Application.kt
fun Application.module() {
    install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
    install(StatusPages) {
        exception<IllegalArgumentException> { call, cause ->
            call.respond(HttpStatusCode.BadRequest, ErrorBody(cause.message ?: "invalid request"))
        }
        exception<Throwable> { call, _ ->
            call.application.log.error("unhandled failure", cause = null)
            call.respond(HttpStatusCode.InternalServerError, ErrorBody("internal error"))
        }
    }
    install(CallLogging) { level = Level.INFO }

    routing {
        route("/api/v1") {
            get("/health") { call.respond(mapOf("status" to "ok")) }
            route("/articles") {
                get {
                    val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
                    call.respond(articleService.page(page))
                }
                get("/{id}") {
                    val id = call.parameters["id"]!!.toLong()
                    val article = articleService.find(id)
                    if (article == null) call.respond(HttpStatusCode.NotFound)
                    else call.respond(article)
                }
                post {
                    val draft = call.receive<ArticleDraft>()
                    call.respond(HttpStatusCode.Created, articleService.create(draft))
                }
            }
        }
    }
}

fun main() {
    val port = System.getenv("PORT")?.toIntOrNull() ?: 8080
    embeddedServer(Netty, port = port, host = "0.0.0.0", module = Application::module)
        .start(wait = true)
}
  • Declare routes as a tree; the nesting keeps a growing API readable and makes the version prefix a single place to change.
  • Install StatusPages so no exception escapes as an unformatted stack trace to a client.
  • Read configuration from environment variables or application.conf, never from constants in the source.
  • toIntOrNull() rather than toInt(): a malformed query parameter should be a 400, not a 500.

Plugins and authentication

fun Application.configureSecurity() {
    install(Authentication) {
        jwt("auth-jwt") {
            realm = "articles"
            verifier(
                JWT.require(Algorithm.HMAC256(System.getenv("JWT_SECRET")))
                    .withIssuer("https://auth.example.com")
                    .build()
            )
            validate { credential ->
                if (credential.payload.getClaim("sub").asString().isNotBlank()) {
                    JWTPrincipal(credential.payload)
                } else null
            }
            challenge { _, _ ->
                call.respond(HttpStatusCode.Unauthorized, ErrorBody("token invalid or expired"))
            }
        }
    }

    install(RequestValidation) {
        validate<ArticleDraft> { draft ->
            if (draft.title.isBlank()) ValidationResult.Invalid("title must not be blank")
            else ValidationResult.Valid
        }
    }
}

// protect a route
authenticate("auth-jwt") {
    post("/articles") { /* ... */ }
}
PluginSolvesWatch for
ContentNegotiationJSON in and outConfigure the Json instance once
StatusPagesConsistent error responsesDo not leak internal messages in production
AuthenticationToken or session checksReturn the right challenge status
CallLoggingRequest logs with a call idLog levels matter under load
CORSBrowser clientsNever allow all origins with credentials

Exposed and lifecycle

object Articles : LongIdTable("articles") {
    val title = varchar("title", 200)
    val publishedAt = timestamp("published_at")
    val author = varchar("author", 120).nullable()
}

class ArticleService(private val db: Database) {
    suspend fun page(page: Int, size: Int = 20): List<ArticleDto> = newSuspendedTransaction(Dispatchers.IO, db) {
        Articles.selectAll()
            .orderBy(Articles.publishedAt to SortOrder.DESC)
            .limit(size).offset(((page - 1) * size).toLong())
            .map(::toDto)
    }
}

// transactional writes commit together or not at all
newSuspendedTransaction(Dispatchers.IO, db) {
    Articles.insert {
        it[title] = draft.title
        it[publishedAt] = Clock.System.now()
    }
}

// graceful shutdown: stop accepting, drain in-flight calls
val server = embeddedServer(Netty, port = port, module = Application::module)
Runtime.getRuntime().addShutdownHook(Thread { server.stop(gracePeriodMillis = 5_000, timeoutMillis = 10_000) })
server.start(wait = true)
⚠️
Run every blocking database call inside Dispatchers.IO. Exposed's JDBC driver blocks its thread, and doing that on the default dispatcher starves the coroutine scheduler under load — the service stays up but latency collapses.

FAQ

Ktor or Spring Boot for a new Kotlin service?
Ktor is small, coroutine-first and starts in milliseconds, which suits containers and serverless. Spring Boot brings a vast ecosystem and a heavier footprint; choose it when you need its modules or your team already runs it.
How should I deploy a Ktor service?
Build a fat jar with the Shadow plugin or a native image with GraalVM, then run it behind a reverse proxy that terminates TLS. Read the port and secrets from environment variables and expose a health endpoint for the orchestrator.

JSON, serialization and HTTP clients Setting up Kotlin: Gradle, the K2 compiler and project layout

Last refreshed 2026-09-18.