| name | ktor |
| description | Ktor framework guardrails, patterns, and best practices for AI-assisted development.
Use when working with Ktor projects, or when the user mentions Ktor.
Provides coroutine-based routing, plugins, serialization, and server/client guidelines.
|
| license | MIT |
| metadata | {"author":"samuel","version":"1.0","category":"framework","language":"kotlin","extensions":".kt"} |
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 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 Characteristics:
- Native Kotlin coroutines (non-blocking by default)
- Modular plugin architecture (install only what you use)
- Type-safe routing DSL
- Built-in test engine (
testApplication)
- Multiple server engines: Netty, Jetty, CIO, Tomcat
- First-class WebSocket support
- Multiplatform HTTP client
Core Principles
- Plugins Over Middleware: Configure behavior via installable plugins, not middleware chains
- Coroutine-First: All request handling is suspending; never block the event loop
- Type-Safe DSL: Use Kotlin's type system for routing, configuration, and serialization
- Explicit Dependencies: Wire services manually or via a lightweight DI container
- HOCON Configuration: Use
application.conf with environment variable overrides
Project Structure
myapp/
โโโ src/main/kotlin/com/example/
โ โโโ Application.kt # Entry point, module composition
โ โโโ plugins/ # Plugin configuration (one file per plugin)
โ โ โโโ Routing.kt
โ โ โโโ Serialization.kt
โ โ โโโ Security.kt
โ โ โโโ StatusPages.kt
โ โ โโโ Validation.kt
โ โ โโโ Databases.kt
โ โโโ routes/ # Route definitions (Route extensions)
โ โ โโโ UserRoutes.kt
โ โ โโโ AuthRoutes.kt
โ โโโ models/ # Data models, DTOs, table definitions
โ โ โโโ User.kt
โ โ โโโ Requests.kt
โ โโโ services/ # Business logic layer
โ โ โโโ UserService.kt
โ โโโ repositories/ # Data access layer (Exposed)
โ โ โโโ UserRepository.kt
โ โโโ utils/ # Utilities (JWT, hashing, etc.)
โ โโโ JwtUtils.kt
โโโ src/main/resources/
โ โโโ application.conf # HOCON configuration
โ โโโ logback.xml
โโโ src/test/kotlin/com/example/
โ โโโ ApplicationTest.kt # Integration tests with testApplication
โ โโโ services/UserServiceTest.kt # Unit tests with MockK
โโโ build.gradle.kts
โโโ gradle.properties
Conventions:
- One plugin configuration per file in
plugins/
- Routes defined as
Route extension functions in routes/
- Services injected via constructor parameters (no global singletons)
models/ contains Exposed table objects, domain models, and serializable DTOs
repositories/ wraps all database access behind suspend functions
Guardrails
Application Module
- Define a single
Application.module() that composes plugins in order
- Plugin install order matters: Serialization before Routing, StatusPages early
- Use
embeddedServer() for simple apps, EngineMain for HOCON-driven startup
- Never put business logic in
Application.kt
fun Application.module() {
configureSerialization()
configureValidation()
configureSecurity()
configureStatusPages()
configureDatabases()
configureRouting()
}
Configuration (HOCON)
- Use
application.conf with environment variable overrides via ${?ENV_VAR}
- Never hardcode secrets; always provide env var fallbacks
- Group related settings under namespaces (
database, jwt, server)
- Access config via
environment.config.property("path").getString()
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 = ${?DATABASE_USER}
password = ${?DATABASE_PASSWORD}
maxPoolSize = 10
}
jwt {
secret = ${?JWT_SECRET}
issuer = "myapp"
audience = "myapp-users"
realm = "myapp"
expirationMs = 3600000
}
Serialization
- Use
kotlinx.serialization with ContentNegotiation plugin
- Configure lenient parsing and unknown key ignoring for forward compatibility
- All DTOs must be
@Serializable data classes
- Separate request DTOs from response DTOs from domain models
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = false
isLenient = true
ignoreUnknownKeys = true
encodeDefaults = true
})
}
}
Routing
- Define routes as
Route extension functions (not inline in configureRouting)
- Group routes under versioned prefixes (
/api/v1/...)
- Use
authenticate("scheme") blocks to protect routes
- Parse path/query parameters with null-safe handling and validation
- Return appropriate HTTP status codes (
Created, NoContent, BadRequest)
fun Route.userRoutes(userService: UserService) {
route("/users") {
post {
val request = call.receive<CreateUserRequest>()
val user = userService.createUser(request)
call.respond(HttpStatusCode.Created, user)
}
authenticate("auth-jwt") {
get {
val limit = call.parameters["limit"]?.toIntOrNull() ?: 20
val offset = call.parameters["offset"]?.toLongOrNull() ?: 0
call.respond(userService.getAllUsers(limit, offset))
}
get("/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID")
call.respond(userService.getUserById(id))
}
}
}
}
Request Validation
- Install
RequestValidation plugin for declarative input validation
- Validate all request DTOs before they reach service layer
- Return structured error messages via
ValidationResult.Invalid
- Handle
RequestValidationException in StatusPages
fun Application.configureValidation() {
install(RequestValidation) {
validate<CreateUserRequest> { req ->
val errors = buildList {
if (!req.email.contains("@")) add("Invalid email format")
if (req.password.length < 8) add("Password must be at least 8 characters")
if (req.name.isBlank()) add("Name is required")
}
if (errors.isNotEmpty()) ValidationResult.Invalid(errors)
else ValidationResult.Valid
}
}
}
Authentication (JWT)
- Use
Authentication plugin with named JWT schemes
- Validate claims in the
validate block; return null to reject
- Return structured error in
challenge block (not raw strings)
- Extract claims from
JWTPrincipal in routes, never parse tokens manually
fun Application.configureSecurity() {
val config = environment.config
install(Authentication) {
jwt("auth-jwt") {
realm = config.property("jwt.realm").getString()
verifier(JWT.require(Algorithm.HMAC256(config.property("jwt.secret").getString()))
.withAudience(config.property("jwt.audience").getString())
.withIssuer(config.property("jwt.issuer").getString())
.build())
validate { credential ->
val userId = credential.payload.getClaim("userId").asString()
if (userId != null) JWTPrincipal(credential.payload) else null
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized,
ErrorResponse(401, "UNAUTHORIZED", "Token is not valid or has expired"))
}
}
}
}
Error Handling (StatusPages)
- Install
StatusPages plugin to centralize exception-to-response mapping
- Define custom exception hierarchy (sealed class or separate classes)
- Never expose internal exception details to clients
- Always include a catch-all
Throwable handler that logs and returns 500
@Serializable
data class ErrorResponse(val status: Int, val error: String, val message: String)
class NotFoundException(message: String) : RuntimeException(message)
class ConflictException(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<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"))
}
}
}
Database (Exposed ORM)
- Use HikariCP connection pool with
newSuspendedTransaction for coroutine safety
- Define tables as
object : LongIdTable("name") in models/
- Wrap all DB calls in a
suspend dbQuery helper
- Separate domain models from Exposed
ResultRow mapping
- Use
SchemaUtils.create() for dev; prefer migrations for production
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 create(email: String, passwordHash: String, name: String): User = dbQuery {
val id = Users.insertAndGetId {
it[Users.email] = email
it[Users.passwordHash] = passwordHash
it[Users.name] = name
}
Users.select { Users.id eq id }.map(User::fromRow).single()
}
}
Service Layer
- Services receive repositories and utilities via constructor injection
- All public methods are
suspend functions
- Throw domain exceptions (
NotFoundException, ConflictException), not generic ones
- Services contain business logic; repositories contain data access only
Coroutine Safety
- Never call blocking I/O without
withContext(Dispatchers.IO)
- Use
withTimeout for external service calls
- Handle
CancellationException correctly (rethrow, never swallow)
- Use
supervisorScope when child failures should not cancel siblings
Testing
Integration Tests (testApplication)
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("test@example.com", "password123", "Test"))
}
assertEquals(HttpStatusCode.Created, response.status)
}
}
Unit Tests (MockK)
class UserServiceTest {
private val repo = mockk<UserRepository>()
private val jwt = mockk<JwtUtils>()
private val service = UserService(repo, jwt)
@Test
fun `createUser with existing email throws ConflictException`() = runBlocking {
coEvery { repo.existsByEmail(any()) } returns true
assertFailsWith<ConflictException> {
service.createUser(CreateUserRequest("dup@test.com", "pass1234", "Dup"))
}
}
@AfterTest fun teardown() { clearAllMocks() }
}
Testing Standards
- Use
testApplication for HTTP-level integration tests
- Use MockK with
coEvery/coVerify for coroutine-aware mocking
- Test names describe behavior:
create user with duplicate email throws ConflictException
- Use H2 in-memory database for integration tests
- Coverage target: >80% for services, >60% overall
Commands
./gradlew run
./gradlew run -t
./gradlew build
./gradlew buildFatJar
java -jar build/libs/app.jar
./gradlew test
./gradlew test --tests "*.UserServiceTest"
./gradlew test --tests "*create user*"
./gradlew ktlintCheck
./gradlew ktlintFormat
./gradlew detekt
./gradlew dependencies
./gradlew clean
Do's and Don'ts
Do
- Configure plugins in separate files under
plugins/
- Use
suspend functions for all database and external calls
- Use Route extension functions to define typed routes
- Use
kotlinx.serialization for JSON (not Jackson/Gson)
- Use
newSuspendedTransaction for coroutine-safe DB access
- Handle errors centrally with StatusPages
- Use
application.conf with env var overrides
- Inject dependencies via constructor parameters
Don't
- Block coroutines with synchronous I/O calls
- Use global mutable state without synchronization
- Expose internal exception messages to API clients
- Hardcode configuration values in source code
- Skip request validation on any user-facing endpoint
- Mix business logic into route handlers
- Use
GlobalScope for request-scoped work
- Install plugins inside route blocks
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), or creating serverless functions.
Consider alternatives when: you need extensive enterprise integrations (Spring Boot),
team is more familiar with Spring, project requires complex security configurations, or
you need a large third-party library ecosystem.
References
For detailed patterns, examples, and advanced topics, see:
- references/patterns.md -- Database integration, WebSocket patterns, testing strategies, deployment, Ktor client
External References