ktor
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.confwith 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
Routeextension functions inroutes/ - Services injected via constructor parameters (no global singletons)
models/contains Exposed table objects, domain models, and serializable DTOsrepositories/wraps all database access behindsuspendfunctions
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,EngineMainfor HOCON-driven startup - Never put business logic in
Application.kt
fun Application.module() {
configureSerialization() // ContentNegotiation first
configureValidation() // RequestValidation before routing
configureSecurity() // Authentication before protected routes
configureStatusPages() // Error handling catches all
configureDatabases() // Database connection pool
configureRouting() // Routes last (depends on all above)
}
Configuration (HOCON)
- Use
application.confwith 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.serializationwithContentNegotiationplugin - Configure lenient parsing and unknown key ignoring for forward compatibility
- All DTOs must be
@Serializabledata classes - Separate request DTOs from response DTOs from domain models
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = false // disable in production
isLenient = true
ignoreUnknownKeys = true
encodeDefaults = true
})
}
}
Routing
- Define routes as
Routeextension functions (not inline inconfigureRouting) - 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
RequestValidationplugin for declarative input validation - Validate all request DTOs before they reach service layer
- Return structured error messages via
ValidationResult.Invalid - Handle
RequestValidationExceptionin 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
Authenticationplugin with named JWT schemes - Validate claims in the
validateblock; returnnullto reject - Return structured error in
challengeblock (not raw strings) - Extract claims from
JWTPrincipalin 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
StatusPagesplugin 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
Throwablehandler 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
newSuspendedTransactionfor coroutine safety - Define tables as
object : LongIdTable("name")inmodels/ - Wrap all DB calls in a
suspend dbQueryhelper - Separate domain models from Exposed
ResultRowmapping - Use
SchemaUtils.create()for dev; prefer migrations for production
// Repository pattern with suspend functions
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
suspendfunctions - 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
withTimeoutfor external service calls - Handle
CancellationExceptioncorrectly (rethrow, never swallow) - Use
supervisorScopewhen 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
testApplicationfor HTTP-level integration tests - Use MockK with
coEvery/coVerifyfor 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
# Development
./gradlew run # Start dev server
./gradlew run -t # Auto-reload on changes
# Build
./gradlew build # Compile + test + check
./gradlew buildFatJar # Build executable fat JAR
java -jar build/libs/app.jar # Run production JAR
# Test
./gradlew test # Run all tests
./gradlew test --tests "*.UserServiceTest" # Specific test class
./gradlew test --tests "*create user*" # Tests matching pattern
# Quality
./gradlew ktlintCheck # Check formatting
./gradlew ktlintFormat # Auto-fix formatting
./gradlew detekt # Static analysis
# Dependencies
./gradlew dependencies # Full dependency tree
./gradlew clean # Clean build artifacts
Do's and Don'ts
Do
- Configure plugins in separate files under
plugins/ - Use
suspendfunctions for all database and external calls - Use Route extension functions to define typed routes
- Use
kotlinx.serializationfor JSON (not Jackson/Gson) - Use
newSuspendedTransactionfor coroutine-safe DB access - Handle errors centrally with StatusPages
- Use
application.confwith 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
GlobalScopefor 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