Website Backend Development with Kotlin (Ktor)
Ktor is an HTTP framework from the Tokio ecosystem, written in Kotlin for Kotlin. It doesn't try to be Spring Boot — no annotation magic, no classpath scanning. The application is assembled manually via DSL: you install plugins, describe routes, configure serialization. This makes behavior predictable and easy to test.
Kotlin coroutines aren't a wrapper on top of threads but a first-class mechanism. Ktor uses them natively: each request is handled in a coroutine, I/O is non-blocking. This provides good performance with modest memory consumption.
Ktor is chosen by: teams with Kotlin/Android background, projects where the coroutine model matters, mobile backend development (Kotlin Multiplatform).
Application setup
// Application.kt
fun main() {
embeddedServer(Netty, port = System.getenv("PORT")?.toInt() ?: 8080) {
configureApplication()
}.start(wait = true)
}
fun Application.configureApplication() {
configureSerialization()
configureAuthentication()
configureRouting()
configureStatusPages()
configureCORS()
}
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = false
isLenient = false
ignoreUnknownKeys = true
encodeDefaults = false
serializersModule = SerializersModule {
// custom serializers
}
})
}
}
fun Application.configureCORS() {
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType)
allowCredentials = true
System.getenv("ALLOWED_ORIGINS")?.split(",")?.forEach { host ->
allowHost(host.trim(), schemes = listOf("https", "http"))
}
}
}
Routing
fun Application.configureRouting() {
routing {
route("/api/v1") {
authRoutes()
route("/products") {
get { /* public */ productHandler.list(call) }
get("/{id}") { productHandler.get(call) }
authenticate("jwt") {
post { productHandler.create(call) }
put("/{id}") { productHandler.update(call) }
delete("/{id}") {
call.requireRole("admin")
productHandler.delete(call)
}
}
}
authenticate("jwt") {
get("/profile") { authHandler.profile(call) }
}
}
}
}
fun Route.authRoutes() {
route("/auth") {
post("/login") { authHandler.login(call) }
post("/refresh") { authHandler.refresh(call) }
}
}
Handler
@Serializable
data class CreateProductRequest(
val name: String,
val price: Double,
val categoryId: Long? = null,
val description: String? = null
)
@Serializable
data class ProductResponse(
val id: Long,
val name: String,
val slug: String,
val price: Double,
val category: CategoryDto? = null,
val createdAt: String
)
class ProductHandler(private val service: ProductService) {
suspend fun list(call: ApplicationCall) {
val page = call.request.queryParameters["page"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1
val limit = call.request.queryParameters["limit"]?.toIntOrNull()
?.coerceIn(1, 100) ?: 20
val categoryId = call.request.queryParameters["category_id"]?.toLongOrNull()
val (products, total) = service.list(page, limit, categoryId)
call.respond(mapOf(
"data" to products,
"pagination" to mapOf("page" to page, "limit" to limit, "total" to total)
))
}
suspend fun create(call: ApplicationCall) {
val req = call.receive<CreateProductRequest>()
validate(req)
val product = service.create(req, call.principal<JWTPrincipal>()!!.userId)
call.respond(HttpStatusCode.Created, product)
}
suspend fun get(call: ApplicationCall) {
val id = call.parameters["id"]?.toLongOrNull()
?: throw BadRequestException("Invalid id")
val product = service.findById(id) ?: throw NotFoundException("Product not found")
call.respond(product)
}
private fun validate(req: CreateProductRequest) {
val errors = mutableMapOf<String, String>()
if (req.name.length < 2) errors["name"] = "Minimum 2 characters"
if (req.price <= 0) errors["price"] = "Price must be greater than zero"
if (errors.isNotEmpty()) throw UnprocessableEntityException(errors)
}
}
JWT authentication
fun Application.configureAuthentication() {
val secret = System.getenv("JWT_SECRET") ?: error("JWT_SECRET not set")
val issuer = System.getenv("JWT_ISSUER") ?: "https://myapp.com"
install(Authentication) {
jwt("jwt") {
realm = "myapp"
verifier(JWT.require(Algorithm.HMAC256(secret)).withIssuer(issuer).build())
validate { credential ->
if (credential.payload.getClaim("sub").asString().isNullOrBlank()) null
else JWTPrincipal(credential.payload)
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Invalid or expired token"))
}
}
}
}
val JWTPrincipal.userId: Long
get() = payload.getClaim("sub").asString().toLong()
val JWTPrincipal.role: String
get() = payload.getClaim("role").asString() ?: "user"
suspend fun ApplicationCall.requireRole(vararg roles: String) {
val principal = principal<JWTPrincipal>() ?: throw UnauthorizedException()
if (principal.role !in roles) {
throw ForbiddenException("Required role: ${roles.joinToString()}")
}
}
Database via Exposed
Exposed is Kotlin SQL library from JetBrains with type-safe DSL:
// Schema declaration
object ProductsTable : LongIdTable("products") {
val name = varchar("name", 255)
val slug = varchar("slug", 255).uniqueIndex()
val price = decimal("price", 10, 2)
val categoryId = long("category_id").nullable()
val isActive = bool("is_active").default(true)
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
}
// Repository
class ProductRepository(private val db: Database) {
suspend fun findAll(page: Int, limit: Int, categoryId: Long?): Pair<List<Product>, Long> =
db.dbQuery {
val query = ProductsTable
.leftJoin(CategoriesTable, { ProductsTable.categoryId }, { CategoriesTable.id })
.select { ProductsTable.isActive eq true }
.apply {
if (categoryId != null) andWhere { ProductsTable.categoryId eq categoryId }
}
val total = query.count()
val products = query
.orderBy(ProductsTable.createdAt to SortOrder.DESC)
.limit(limit, offset = ((page - 1) * limit).toLong())
.map { toProduct(it) }
products to total
}
suspend fun insert(dto: CreateProductRequest): Product = db.dbQuery {
val id = ProductsTable.insertAndGetId {
it[name] = dto.name
it[slug] = dto.name.toSlug()
it[price] = dto.price.toBigDecimal()
}.value
ProductsTable.select { ProductsTable.id eq id }.first().let { toProduct(it) }
}
}
// Helper for coroutines
suspend fun <T> Database.dbQuery(block: () -> T): T =
withContext(Dispatchers.IO) {
transaction { block() }
}
Error handling
fun Application.configureStatusPages() {
install(StatusPages) {
exception<BadRequestException> { call, cause ->
call.respond(HttpStatusCode.BadRequest, mapOf("error" to cause.message))
}
exception<NotFoundException> { call, cause ->
call.respond(HttpStatusCode.NotFound, mapOf("error" to cause.message))
}
exception<UnprocessableEntityException> { call, cause ->
call.respond(HttpStatusCode.UnprocessableEntity, mapOf("errors" to cause.errors))
}
exception<ForbiddenException> { call, cause ->
call.respond(HttpStatusCode.Forbidden, mapOf("error" to cause.message))
}
exception<Throwable> { call, cause ->
application.log.error("Unhandled exception", cause)
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to "Internal server error"))
}
}
}
Testing
Ktor has excellent test support via testApplication:
class ProductRouteTest {
@Test
fun `GET products returns paginated list`() = testApplication {
application {
configureApplication()
// Replace dependencies with mocks
}
val response = client.get("/api/v1/products?page=1&limit=10")
assertEquals(HttpStatusCode.OK, response.status)
val body = Json.decodeFromString<Map<String, Any>>(response.bodyAsText())
assertNotNull(body["data"])
assertNotNull(body["pagination"])
}
@Test
fun `POST products returns 401 without token`() = testApplication {
application { configureApplication() }
val response = client.post("/api/v1/products") {
contentType(ContentType.Application.Json)
setBody("""{"name": "Test", "price": 10.0}""")
}
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
}
Development timeline
- Setup + plugins + DI (Koin) — 4–6 days
- Routing + handlers + serialization — 1–1.5 weeks
- Auth + JWT — 3–5 days
- Database layer (Exposed + Flyway migrations) — 1 week
- Tests — 1 week
- Docker + CI — 2–3 days
Mid-scale backend: 7–12 weeks. Ktor is an excellent choice for teams with Kotlin experience, especially with an Android/KMP project where you can reuse models and logic.







