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
StatusPagesso 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 thantoInt(): 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") { /* ... */ }
}| Plugin | Solves | Watch for |
|---|---|---|
ContentNegotiation | JSON in and out | Configure the Json instance once |
StatusPages | Consistent error responses | Do not leak internal messages in production |
Authentication | Token or session checks | Return the right challenge status |
CallLogging | Request logs with a call id | Log levels matter under load |
CORS | Browser clients | Never 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.
Related
JSON, serialization and HTTP clients Setting up Kotlin: Gradle, the K2 compiler and project layout
Last refreshed 2026-09-18.