Ktor Framework Guide¶
Applies to: Ktor 2.x, Kotlin 1.9+, Microservices, REST APIs, WebSockets
Overview¶
Ktor is a lightweight, asynchronous framework built by JetBrains specifically for Kotlin. It leverages coroutines for non-blocking I/O and provides a flexible plugin system for extensibility.
Best For: Microservices, lightweight APIs, real-time applications, Kotlin-native projects
Key Features: - Native Kotlin coroutines support - Lightweight and modular (only include what you need) - Type-safe routing with DSL - Built-in testing support - Multiple server engines (Netty, Jetty, CIO, Tomcat) - WebSocket support out of the box - Multiplatform client
Project Structure¶
myapp/
├── src/
│ └── main/
│ ├── kotlin/
│ │ └── com/example/
│ │ ├── Application.kt # Entry point
│ │ ├── plugins/ # Ktor plugins configuration
│ │ │ ├── Routing.kt
│ │ │ ├── Serialization.kt
│ │ │ ├── Security.kt
│ │ │ ├── StatusPages.kt
│ │ │ └── Databases.kt
│ │ ├── routes/ # Route definitions
│ │ │ ├── UserRoutes.kt
│ │ │ └── AuthRoutes.kt
│ │ ├── models/ # Data models
│ │ │ ├── User.kt
│ │ │ └── Requests.kt
│ │ ├── services/ # Business logic
│ │ │ └── UserService.kt
│ │ ├── repositories/ # Data access
│ │ │ └── UserRepository.kt
│ │ └── utils/ # Utilities
│ │ └── JwtUtils.kt
│ └── resources/
│ ├── application.conf # HOCON configuration
│ └── logback.xml
├── src/
│ └── test/
│ └── kotlin/
│ └── com/example/
│ └── ApplicationTest.kt
├── build.gradle.kts
└── gradle.properties
Dependencies (build.gradle.kts)¶
plugins {
kotlin("jvm") version "1.9.22"
kotlin("plugin.serialization") version "1.9.22"
id("io.ktor.plugin") version "2.3.7"
}
group = "com.example"
version = "0.0.1"
application {
mainClass.set("com.example.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
repositories {
mavenCentral()
}
dependencies {
// Ktor Server
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-netty-jvm")
implementation("io.ktor:ktor-server-host-common-jvm")
// Content Negotiation & Serialization
implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
// Authentication
implementation("io.ktor:ktor-server-auth-jvm")
implementation("io.ktor:ktor-server-auth-jwt-jvm")
// Validation
implementation("io.ktor:ktor-server-request-validation")
// Status Pages (Error Handling)
implementation("io.ktor:ktor-server-status-pages-jvm")
// CORS
implementation("io.ktor:ktor-server-cors-jvm")
// Call Logging
implementation("io.ktor:ktor-server-call-logging-jvm")
// Database
implementation("org.jetbrains.exposed:exposed-core:0.45.0")
implementation("org.jetbrains.exposed:exposed-dao:0.45.0")
implementation("org.jetbrains.exposed:exposed-jdbc:0.45.0")
implementation("org.jetbrains.exposed:exposed-java-time:0.45.0")
implementation("com.zaxxer:HikariCP:5.1.0")
implementation("org.postgresql:postgresql:42.7.1")
// Password Hashing
implementation("at.favre.lib:bcrypt:0.10.2")
// Logging
implementation("ch.qos.logback:logback-classic:1.4.14")
// Testing
testImplementation("io.ktor:ktor-server-tests-jvm")
testImplementation("io.ktor:ktor-client-content-negotiation")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.22")
testImplementation("io.mockk:mockk:1.13.9")
testImplementation("com.h2database:h2:2.2.224")
}
ktor {
fatJar {
archiveFileName.set("app.jar")
}
}
Application Entry Point¶
// src/main/kotlin/com/example/Application.kt
package com.example
import com.example.plugins.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
configureSerialization()
configureDatabases()
configureSecurity()
configureStatusPages()
configureRouting()
}
Configuration (application.conf)¶
# src/main/resources/application.conf
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ com.example.ApplicationKt.module ]
}
}
database {
url = "jdbc:postgresql://localhost:5432/myapp"
url = ${?DATABASE_URL}
driver = "org.postgresql.Driver"
user = "postgres"
user = ${?DATABASE_USER}
password = "password"
password = ${?DATABASE_PASSWORD}
maxPoolSize = 10
}
jwt {
secret = "your-256-bit-secret-key-here-change-in-production"
secret = ${?JWT_SECRET}
issuer = "myapp"
audience = "myapp-users"
realm = "myapp"
expirationMs = 3600000
}
Plugin Configuration¶
Serialization Plugin¶
// src/main/kotlin/com/example/plugins/Serialization.kt
package com.example.plugins
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import kotlinx.serialization.json.Json
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
encodeDefaults = true
})
}
}
Database Plugin (Exposed)¶
// src/main/kotlin/com/example/plugins/Databases.kt
package com.example.plugins
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.*
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
import com.example.repositories.Users
fun Application.configureDatabases() {
val config = environment.config
val hikariConfig = HikariConfig().apply {
jdbcUrl = config.property("database.url").getString()
driverClassName = config.property("database.driver").getString()
username = config.property("database.user").getString()
password = config.property("database.password").getString()
maximumPoolSize = config.property("database.maxPoolSize").getString().toInt()
isAutoCommit = false
transactionIsolation = "TRANSACTION_REPEATABLE_READ"
validate()
}
val dataSource = HikariDataSource(hikariConfig)
Database.connect(dataSource)
// Create tables
transaction {
SchemaUtils.create(Users)
}
}
Security Plugin (JWT)¶
// src/main/kotlin/com/example/plugins/Security.kt
package com.example.plugins
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.response.*
fun Application.configureSecurity() {
val config = environment.config
val jwtSecret = config.property("jwt.secret").getString()
val jwtIssuer = config.property("jwt.issuer").getString()
val jwtAudience = config.property("jwt.audience").getString()
val jwtRealm = config.property("jwt.realm").getString()
install(Authentication) {
jwt("auth-jwt") {
realm = jwtRealm
verifier(
JWT
.require(Algorithm.HMAC256(jwtSecret))
.withAudience(jwtAudience)
.withIssuer(jwtIssuer)
.build()
)
validate { credential ->
val userId = credential.payload.getClaim("userId").asString()
val email = credential.payload.getClaim("email").asString()
if (userId != null && email != null) {
JWTPrincipal(credential.payload)
} else {
null
}
}
challenge { _, _ ->
call.respond(
HttpStatusCode.Unauthorized,
mapOf("error" to "Token is not valid or has expired")
)
}
}
}
}
Status Pages (Error Handling)¶
// src/main/kotlin/com/example/plugins/StatusPages.kt
package com.example.plugins
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.*
import io.ktor.server.plugins.requestvalidation.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
import kotlinx.serialization.Serializable
@Serializable
data class ErrorResponse(
val status: Int,
val error: String,
val message: String,
)
// Custom Exceptions
class NotFoundException(message: String) : RuntimeException(message)
class UnauthorizedException(message: String) : RuntimeException(message)
class ConflictException(message: String) : RuntimeException(message)
class BadRequestException(message: String) : RuntimeException(message)
fun Application.configureStatusPages() {
install(StatusPages) {
exception<NotFoundException> { call, cause ->
call.respond(
HttpStatusCode.NotFound,
ErrorResponse(404, "NOT_FOUND", cause.message ?: "Resource not found")
)
}
exception<UnauthorizedException> { call, cause ->
call.respond(
HttpStatusCode.Unauthorized,
ErrorResponse(401, "UNAUTHORIZED", cause.message ?: "Unauthorized")
)
}
exception<ConflictException> { call, cause ->
call.respond(
HttpStatusCode.Conflict,
ErrorResponse(409, "CONFLICT", cause.message ?: "Resource conflict")
)
}
exception<BadRequestException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(400, "BAD_REQUEST", cause.message ?: "Invalid request")
)
}
exception<RequestValidationException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(
400,
"VALIDATION_ERROR",
cause.reasons.joinToString("; ")
)
)
}
exception<Throwable> { call, cause ->
call.application.environment.log.error("Unhandled exception", cause)
call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse(500, "INTERNAL_ERROR", "An unexpected error occurred")
)
}
}
}
Models¶
User Entity (Exposed)¶
// src/main/kotlin/com/example/models/User.kt
package com.example.models
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.dao.id.LongIdTable
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.javatime.datetime
import java.time.LocalDateTime
// Database Table
object Users : LongIdTable("users") {
val email = varchar("email", 255).uniqueIndex()
val passwordHash = varchar("password_hash", 255)
val name = varchar("name", 255)
val role = varchar("role", 50).default("user")
val createdAt = datetime("created_at").default(LocalDateTime.now())
val updatedAt = datetime("updated_at").default(LocalDateTime.now())
}
// Domain Model
data class User(
val id: Long,
val email: String,
val passwordHash: String,
val name: String,
val role: String,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
) {
companion object {
fun fromRow(row: ResultRow): User = User(
id = row[Users.id].value,
email = row[Users.email],
passwordHash = row[Users.passwordHash],
name = row[Users.name],
role = row[Users.role],
createdAt = row[Users.createdAt],
updatedAt = row[Users.updatedAt],
)
}
}
// Response DTO
@Serializable
data class UserResponse(
val id: Long,
val email: String,
val name: String,
val role: String,
val createdAt: String,
) {
companion object {
fun from(user: User): UserResponse = UserResponse(
id = user.id,
email = user.email,
name = user.name,
role = user.role,
createdAt = user.createdAt.toString(),
)
}
}
Request DTOs¶
// src/main/kotlin/com/example/models/Requests.kt
package com.example.models
import kotlinx.serialization.Serializable
@Serializable
data class CreateUserRequest(
val email: String,
val password: String,
val name: String,
)
@Serializable
data class UpdateUserRequest(
val name: String? = null,
val email: String? = null,
)
@Serializable
data class LoginRequest(
val email: String,
val password: String,
)
@Serializable
data class LoginResponse(
val token: String,
val user: UserResponse,
)
Request Validation¶
// src/main/kotlin/com/example/plugins/Validation.kt
package com.example.plugins
import com.example.models.CreateUserRequest
import com.example.models.LoginRequest
import io.ktor.server.application.*
import io.ktor.server.plugins.requestvalidation.*
fun Application.configureValidation() {
install(RequestValidation) {
validate<CreateUserRequest> { request ->
val errors = mutableListOf<String>()
if (!request.email.contains("@")) {
errors.add("Invalid email format")
}
if (request.password.length < 8) {
errors.add("Password must be at least 8 characters")
}
if (request.name.isBlank()) {
errors.add("Name is required")
}
if (errors.isNotEmpty()) {
ValidationResult.Invalid(errors)
} else {
ValidationResult.Valid
}
}
validate<LoginRequest> { request ->
if (request.email.isBlank() || request.password.isBlank()) {
ValidationResult.Invalid("Email and password are required")
} else {
ValidationResult.Valid
}
}
}
}
Repository¶
// src/main/kotlin/com/example/repositories/UserRepository.kt
package com.example.repositories
import com.example.models.User
import com.example.models.Users
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import java.time.LocalDateTime
class UserRepository {
private suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction { block() }
suspend fun findById(id: Long): User? = dbQuery {
Users.select { Users.id eq id }
.map(User::fromRow)
.singleOrNull()
}
suspend fun findByEmail(email: String): User? = dbQuery {
Users.select { Users.email eq email }
.map(User::fromRow)
.singleOrNull()
}
suspend fun findAll(limit: Int = 20, offset: Long = 0): List<User> = dbQuery {
Users.selectAll()
.orderBy(Users.createdAt, SortOrder.DESC)
.limit(limit, offset)
.map(User::fromRow)
}
suspend fun create(
email: String,
passwordHash: String,
name: String,
role: String = "user",
): User = dbQuery {
val id = Users.insertAndGetId {
it[Users.email] = email
it[Users.passwordHash] = passwordHash
it[Users.name] = name
it[Users.role] = role
it[createdAt] = LocalDateTime.now()
it[updatedAt] = LocalDateTime.now()
}
Users.select { Users.id eq id }
.map(User::fromRow)
.single()
}
suspend fun update(id: Long, name: String?, email: String?): User? = dbQuery {
val updated = Users.update({ Users.id eq id }) {
name?.let { n -> it[Users.name] = n }
email?.let { e -> it[Users.email] = e }
it[updatedAt] = LocalDateTime.now()
}
if (updated > 0) {
Users.select { Users.id eq id }
.map(User::fromRow)
.singleOrNull()
} else {
null
}
}
suspend fun delete(id: Long): Boolean = dbQuery {
Users.deleteWhere { Users.id eq id } > 0
}
suspend fun existsByEmail(email: String): Boolean = dbQuery {
Users.select { Users.email eq email }.count() > 0
}
}
Service¶
// src/main/kotlin/com/example/services/UserService.kt
package com.example.services
import at.favre.lib.crypto.bcrypt.BCrypt
import com.example.models.*
import com.example.plugins.ConflictException
import com.example.plugins.NotFoundException
import com.example.plugins.UnauthorizedException
import com.example.repositories.UserRepository
import com.example.utils.JwtUtils
class UserService(
private val userRepository: UserRepository,
private val jwtUtils: JwtUtils,
) {
suspend fun createUser(request: CreateUserRequest): UserResponse {
if (userRepository.existsByEmail(request.email)) {
throw ConflictException("Email already registered")
}
val passwordHash = BCrypt.withDefaults()
.hashToString(12, request.password.toCharArray())
val user = userRepository.create(
email = request.email,
passwordHash = passwordHash,
name = request.name,
)
return UserResponse.from(user)
}
suspend fun getUserById(id: Long): UserResponse {
val user = userRepository.findById(id)
?: throw NotFoundException("User not found")
return UserResponse.from(user)
}
suspend fun getAllUsers(limit: Int, offset: Long): List<UserResponse> {
return userRepository.findAll(limit, offset)
.map(UserResponse::from)
}
suspend fun updateUser(id: Long, request: UpdateUserRequest): UserResponse {
// Check email uniqueness if updating email
if (request.email != null) {
val existing = userRepository.findByEmail(request.email)
if (existing != null && existing.id != id) {
throw ConflictException("Email already in use")
}
}
val user = userRepository.update(id, request.name, request.email)
?: throw NotFoundException("User not found")
return UserResponse.from(user)
}
suspend fun deleteUser(id: Long) {
if (!userRepository.delete(id)) {
throw NotFoundException("User not found")
}
}
suspend fun login(request: LoginRequest): LoginResponse {
val user = userRepository.findByEmail(request.email)
?: throw UnauthorizedException("Invalid credentials")
val passwordValid = BCrypt.verifyer()
.verify(request.password.toCharArray(), user.passwordHash)
.verified
if (!passwordValid) {
throw UnauthorizedException("Invalid credentials")
}
val token = jwtUtils.generateToken(user)
return LoginResponse(
token = token,
user = UserResponse.from(user),
)
}
}
JWT Utilities¶
// src/main/kotlin/com/example/utils/JwtUtils.kt
package com.example.utils
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import com.example.models.User
import java.util.*
class JwtUtils(
private val secret: String,
private val issuer: String,
private val audience: String,
private val expirationMs: Long,
) {
fun generateToken(user: User): String {
return JWT.create()
.withAudience(audience)
.withIssuer(issuer)
.withClaim("userId", user.id.toString())
.withClaim("email", user.email)
.withClaim("role", user.role)
.withExpiresAt(Date(System.currentTimeMillis() + expirationMs))
.sign(Algorithm.HMAC256(secret))
}
}
Routing¶
Main Routing Configuration¶
// src/main/kotlin/com/example/plugins/Routing.kt
package com.example.plugins
import com.example.repositories.UserRepository
import com.example.routes.authRoutes
import com.example.routes.userRoutes
import com.example.services.UserService
import com.example.utils.JwtUtils
import io.ktor.server.application.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.request.*
import io.ktor.server.routing.*
import org.slf4j.event.Level
fun Application.configureRouting() {
// CORS
install(CORS) {
anyHost()
allowHeader("Content-Type")
allowHeader("Authorization")
allowMethod(io.ktor.http.HttpMethod.Options)
allowMethod(io.ktor.http.HttpMethod.Put)
allowMethod(io.ktor.http.HttpMethod.Delete)
}
// Call Logging
install(CallLogging) {
level = Level.INFO
filter { call -> call.request.path().startsWith("/api") }
format { call ->
val status = call.response.status()
val method = call.request.httpMethod.value
val path = call.request.path()
"$method $path - $status"
}
}
// Initialize dependencies
val config = environment.config
val userRepository = UserRepository()
val jwtUtils = JwtUtils(
secret = config.property("jwt.secret").getString(),
issuer = config.property("jwt.issuer").getString(),
audience = config.property("jwt.audience").getString(),
expirationMs = config.property("jwt.expirationMs").getString().toLong(),
)
val userService = UserService(userRepository, jwtUtils)
routing {
route("/api") {
authRoutes(userService)
userRoutes(userService)
}
}
}
User Routes¶
// src/main/kotlin/com/example/routes/UserRoutes.kt
package com.example.routes
import com.example.models.CreateUserRequest
import com.example.models.UpdateUserRequest
import com.example.services.UserService
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.userRoutes(userService: UserService) {
route("/users") {
// Public: Create user
post {
val request = call.receive<CreateUserRequest>()
val user = userService.createUser(request)
call.respond(HttpStatusCode.Created, user)
}
// Protected routes
authenticate("auth-jwt") {
// Get all users
get {
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 20
val offset = call.request.queryParameters["offset"]?.toLongOrNull() ?: 0
val users = userService.getAllUsers(limit, offset)
call.respond(users)
}
// Get current user
get("/me") {
val principal = call.principal<JWTPrincipal>()
val userId = principal!!.payload.getClaim("userId").asString().toLong()
val user = userService.getUserById(userId)
call.respond(user)
}
// Get user by ID
get("/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID")
val user = userService.getUserById(id)
call.respond(user)
}
// Update user
put("/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@put call.respond(HttpStatusCode.BadRequest, "Invalid ID")
val request = call.receive<UpdateUserRequest>()
val user = userService.updateUser(id, request)
call.respond(user)
}
// Delete user
delete("/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@delete call.respond(HttpStatusCode.BadRequest, "Invalid ID")
userService.deleteUser(id)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
Auth Routes¶
// src/main/kotlin/com/example/routes/AuthRoutes.kt
package com.example.routes
import com.example.models.LoginRequest
import com.example.services.UserService
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.authRoutes(userService: UserService) {
route("/auth") {
post("/login") {
val request = call.receive<LoginRequest>()
val response = userService.login(request)
call.respond(response)
}
}
}
WebSocket Support¶
// src/main/kotlin/com/example/routes/WebSocketRoutes.kt
package com.example.routes
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.channels.consumeEach
import java.util.concurrent.ConcurrentHashMap
class ConnectionManager {
private val connections = ConcurrentHashMap<String, WebSocketSession>()
fun addConnection(userId: String, session: WebSocketSession) {
connections[userId] = session
}
fun removeConnection(userId: String) {
connections.remove(userId)
}
suspend fun broadcast(message: String) {
connections.values.forEach { session ->
session.send(Frame.Text(message))
}
}
suspend fun sendTo(userId: String, message: String) {
connections[userId]?.send(Frame.Text(message))
}
}
fun Route.webSocketRoutes(connectionManager: ConnectionManager) {
webSocket("/ws/{userId}") {
val userId = call.parameters["userId"] ?: return@webSocket close(
CloseReason(CloseReason.Codes.VIOLATED_POLICY, "No user ID")
)
connectionManager.addConnection(userId, this)
try {
incoming.consumeEach { frame ->
when (frame) {
is Frame.Text -> {
val text = frame.readText()
connectionManager.broadcast("$userId: $text")
}
else -> {}
}
}
} finally {
connectionManager.removeConnection(userId)
}
}
}
Testing¶
Application Test Setup¶
// src/test/kotlin/com/example/ApplicationTest.kt
package com.example
import com.example.models.CreateUserRequest
import com.example.models.LoginRequest
import com.example.models.LoginResponse
import com.example.models.UserResponse
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.testing.*
import kotlin.test.*
class ApplicationTest {
@Test
fun `create user returns 201`() = testApplication {
application {
module()
}
val client = createClient {
install(ContentNegotiation) {
json()
}
}
val response = client.post("/api/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest(
email = "test@example.com",
password = "password123",
name = "Test User",
))
}
assertEquals(HttpStatusCode.Created, response.status)
val user = response.body<UserResponse>()
assertEquals("test@example.com", user.email)
assertEquals("Test User", user.name)
}
@Test
fun `login returns token`() = testApplication {
application {
module()
}
val client = createClient {
install(ContentNegotiation) {
json()
}
}
// First create user
client.post("/api/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest(
email = "login@example.com",
password = "password123",
name = "Login User",
))
}
// Then login
val response = client.post("/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(
email = "login@example.com",
password = "password123",
))
}
assertEquals(HttpStatusCode.OK, response.status)
val loginResponse = response.body<LoginResponse>()
assertNotNull(loginResponse.token)
}
@Test
fun `get users requires authentication`() = testApplication {
application {
module()
}
val response = client.get("/api/users")
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `get users with valid token returns users`() = testApplication {
application {
module()
}
val client = createClient {
install(ContentNegotiation) {
json()
}
}
// Create and login
client.post("/api/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest(
email = "auth@example.com",
password = "password123",
name = "Auth User",
))
}
val loginResponse = client.post("/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(
email = "auth@example.com",
password = "password123",
))
}.body<LoginResponse>()
// Use token
val response = client.get("/api/users") {
header(HttpHeaders.Authorization, "Bearer ${loginResponse.token}")
}
assertEquals(HttpStatusCode.OK, response.status)
val users = response.body<List<UserResponse>>()
assertTrue(users.isNotEmpty())
}
}
Service Unit Tests¶
// src/test/kotlin/com/example/services/UserServiceTest.kt
package com.example.services
import com.example.models.CreateUserRequest
import com.example.models.User
import com.example.plugins.ConflictException
import com.example.plugins.NotFoundException
import com.example.repositories.UserRepository
import com.example.utils.JwtUtils
import io.mockk.*
import kotlinx.coroutines.runBlocking
import java.time.LocalDateTime
import kotlin.test.*
class UserServiceTest {
private lateinit var userRepository: UserRepository
private lateinit var jwtUtils: JwtUtils
private lateinit var userService: UserService
@BeforeTest
fun setup() {
userRepository = mockk()
jwtUtils = mockk()
userService = UserService(userRepository, jwtUtils)
}
@AfterTest
fun teardown() {
clearAllMocks()
}
@Test
fun `createUser with valid data returns user`() = runBlocking {
val request = CreateUserRequest(
email = "test@example.com",
password = "password123",
name = "Test User",
)
val user = User(
id = 1,
email = "test@example.com",
passwordHash = "hashed",
name = "Test User",
role = "user",
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(),
)
coEvery { userRepository.existsByEmail(any()) } returns false
coEvery { userRepository.create(any(), any(), any(), any()) } returns user
val result = userService.createUser(request)
assertEquals("test@example.com", result.email)
assertEquals("Test User", result.name)
coVerify { userRepository.create(any(), any(), any(), any()) }
}
@Test
fun `createUser with existing email throws ConflictException`(): Unit = runBlocking {
val request = CreateUserRequest(
email = "existing@example.com",
password = "password123",
name = "Test User",
)
coEvery { userRepository.existsByEmail(any()) } returns true
assertFailsWith<ConflictException> {
userService.createUser(request)
}
}
@Test
fun `getUserById with non-existent id throws NotFoundException`(): Unit = runBlocking {
coEvery { userRepository.findById(any()) } returns null
assertFailsWith<NotFoundException> {
userService.getUserById(999)
}
}
}
Commands¶
# Development
./gradlew run
# Build
./gradlew build
# Build fat JAR
./gradlew buildFatJar
# Run fat JAR
java -jar build/libs/app.jar
# Test
./gradlew test
# Format (if using ktlint)
./gradlew ktlintFormat
# Clean
./gradlew clean
Best Practices¶
Do's¶
- ✅ Use dependency injection pattern (pass services to routes)
- ✅ Use
suspendfunctions for async database operations - ✅ Configure plugins in separate files
- ✅ Use typed routes with Route extension functions
- ✅ Use
kotlinx.serializationfor JSON - ✅ Use Exposed's
newSuspendedTransactionfor coroutine-safe DB access - ✅ Handle errors with StatusPages plugin
- ✅ Use application.conf for configuration
Don'ts¶
- ❌ Don't block coroutines with synchronous calls
- ❌ Don't use global state without proper synchronization
- ❌ Don't expose internal exceptions to clients
- ❌ Don't hardcode configuration values
- ❌ Don't skip request validation
- ❌ Don't mix business logic into routes
Comparison: Ktor vs Spring Boot (Kotlin)¶
| Feature | Ktor | Spring Boot (Kotlin) |
|---|---|---|
| Weight | Lightweight, modular | Full-featured, heavier |
| Learning Curve | Lower (Kotlin-native) | Moderate (Spring ecosystem) |
| Coroutines | Native, first-class | Supported (WebFlux) |
| Configuration | HOCON/programmatic | YAML/properties, annotations |
| ORM | Exposed (recommended) | JPA/Hibernate |
| Testing | Built-in testApplication | Spring Test |
| Best For | Microservices, lightweight APIs | Enterprise, full-stack apps |
| Startup Time | Very fast | Slower (reflection) |
| Memory | Lower footprint | Higher footprint |
| Ecosystem | Growing | Mature, extensive |
When to Use Ktor¶
Choose Ktor when: - Building lightweight microservices - Kotlin is your primary language - You need fast startup times - You want fine-grained control over dependencies - Building real-time applications (WebSockets) - Creating serverless functions
Consider alternatives when: - You need extensive enterprise integrations - Team is more familiar with Spring - Project requires complex security configurations - You need extensive third-party library support