Ktor Server Patterns
Comprehensive Ktor patterns for building robust, maintainable HTTP servers with Kotlin coroutines.
When to Activate
- Building Ktor HTTP servers
- Configuring Ktor plugins (Auth, CORS, ContentNegotiation, StatusPages)
- Implementing REST APIs with Ktor
- Setting up dependency injection with Koin
- Writing Ktor integration tests with testApplication
- Working with WebSockets in Ktor
Application Structure
Standard Ktor Project Layout
text
1src/main/kotlin/
2├── com/example/
3│ ├── Application.kt # Entry point, module configuration
4│ ├── plugins/
5│ │ ├── Routing.kt # Route definitions
6│ │ ├── Serialization.kt # Content negotiation setup
7│ │ ├── Authentication.kt # Auth configuration
8│ │ ├── StatusPages.kt # Error handling
9│ │ └── CORS.kt # CORS configuration
10│ ├── routes/
11│ │ ├── UserRoutes.kt # /users endpoints
12│ │ ├── AuthRoutes.kt # /auth endpoints
13│ │ └── HealthRoutes.kt # /health endpoints
14│ ├── models/
15│ │ ├── User.kt # Domain models
16│ │ └── ApiResponse.kt # Response envelopes
17│ ├── services/
18│ │ ├── UserService.kt # Business logic
19│ │ └── AuthService.kt # Auth logic
20│ ├── repositories/
21│ │ ├── UserRepository.kt # Data access interface
22│ │ └── ExposedUserRepository.kt
23│ └── di/
24│ └── AppModule.kt # Koin modules
25src/test/kotlin/
26├── com/example/
27│ ├── routes/
28│ │ └── UserRoutesTest.kt
29│ └── services/
30│ └── UserServiceTest.kt
Application Entry Point
kotlin
1// Application.kt
2fun main() {
3 embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
4}
5
6fun Application.module() {
7 configureSerialization()
8 configureAuthentication()
9 configureStatusPages()
10 configureCORS()
11 configureDI()
12 configureRouting()
13}
Routing DSL
Basic Routes
kotlin
1// plugins/Routing.kt
2fun Application.configureRouting() {
3 routing {
4 userRoutes()
5 authRoutes()
6 healthRoutes()
7 }
8}
9
10// routes/UserRoutes.kt
11fun Route.userRoutes() {
12 val userService by inject<UserService>()
13
14 route("/users") {
15 get {
16 val users = userService.getAll()
17 call.respond(users)
18 }
19
20 get("/{id}") {
21 val id = call.parameters["id"]
22 ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing id")
23 val user = userService.getById(id)
24 ?: return@get call.respond(HttpStatusCode.NotFound)
25 call.respond(user)
26 }
27
28 post {
29 val request = call.receive<CreateUserRequest>()
30 val user = userService.create(request)
31 call.respond(HttpStatusCode.Created, user)
32 }
33
34 put("/{id}") {
35 val id = call.parameters["id"]
36 ?: return@put call.respond(HttpStatusCode.BadRequest, "Missing id")
37 val request = call.receive<UpdateUserRequest>()
38 val user = userService.update(id, request)
39 ?: return@put call.respond(HttpStatusCode.NotFound)
40 call.respond(user)
41 }
42
43 delete("/{id}") {
44 val id = call.parameters["id"]
45 ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing id")
46 val deleted = userService.delete(id)
47 if (deleted) call.respond(HttpStatusCode.NoContent)
48 else call.respond(HttpStatusCode.NotFound)
49 }
50 }
51}
Route Organization with Authenticated Routes
kotlin
1fun Route.userRoutes() {
2 route("/users") {
3 // Public routes
4 get { /* list users */ }
5 get("/{id}") { /* get user */ }
6
7 // Protected routes
8 authenticate("jwt") {
9 post { /* create user - requires auth */ }
10 put("/{id}") { /* update user - requires auth */ }
11 delete("/{id}") { /* delete user - requires auth */ }
12 }
13 }
14}
Content Negotiation & Serialization
kotlinx.serialization Setup
kotlin
1// plugins/Serialization.kt
2fun Application.configureSerialization() {
3 install(ContentNegotiation) {
4 json(Json {
5 prettyPrint = true
6 isLenient = false
7 ignoreUnknownKeys = true
8 encodeDefaults = true
9 explicitNulls = false
10 })
11 }
12}
Serializable Models
kotlin
1@Serializable
2data class UserResponse(
3 val id: String,
4 val name: String,
5 val email: String,
6 val role: Role,
7 @Serializable(with = InstantSerializer::class)
8 val createdAt: Instant,
9)
10
11@Serializable
12data class CreateUserRequest(
13 val name: String,
14 val email: String,
15 val role: Role = Role.USER,
16)
17
18@Serializable
19data class ApiResponse<T>(
20 val success: Boolean,
21 val data: T? = null,
22 val error: String? = null,
23) {
24 companion object {
25 fun <T> ok(data: T): ApiResponse<T> = ApiResponse(success = true, data = data)
26 fun <T> error(message: String): ApiResponse<T> = ApiResponse(success = false, error = message)
27 }
28}
29
30@Serializable
31data class PaginatedResponse<T>(
32 val data: List<T>,
33 val total: Long,
34 val page: Int,
35 val limit: Int,
36)
Custom Serializers
kotlin
1object InstantSerializer : KSerializer<Instant> {
2 override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
3 override fun serialize(encoder: Encoder, value: Instant) =
4 encoder.encodeString(value.toString())
5 override fun deserialize(decoder: Decoder): Instant =
6 Instant.parse(decoder.decodeString())
7}
Authentication
JWT Authentication
kotlin
1// plugins/Authentication.kt
2fun Application.configureAuthentication() {
3 val jwtSecret = environment.config.property("jwt.secret").getString()
4 val jwtIssuer = environment.config.property("jwt.issuer").getString()
5 val jwtAudience = environment.config.property("jwt.audience").getString()
6 val jwtRealm = environment.config.property("jwt.realm").getString()
7
8 install(Authentication) {
9 jwt("jwt") {
10 realm = jwtRealm
11 verifier(
12 JWT.require(Algorithm.HMAC256(jwtSecret))
13 .withAudience(jwtAudience)
14 .withIssuer(jwtIssuer)
15 .build()
16 )
17 validate { credential ->
18 if (credential.payload.audience.contains(jwtAudience)) {
19 JWTPrincipal(credential.payload)
20 } else {
21 null
22 }
23 }
24 challenge { _, _ ->
25 call.respond(HttpStatusCode.Unauthorized, ApiResponse.error<Unit>("Invalid or expired token"))
26 }
27 }
28 }
29}
30
31// Extracting user from JWT
32fun ApplicationCall.userId(): String =
33 principal<JWTPrincipal>()
34 ?.payload
35 ?.getClaim("userId")
36 ?.asString()
37 ?: throw AuthenticationException("No userId in token")
Auth Routes
kotlin
1fun Route.authRoutes() {
2 val authService by inject<AuthService>()
3
4 route("/auth") {
5 post("/login") {
6 val request = call.receive<LoginRequest>()
7 val token = authService.login(request.email, request.password)
8 ?: return@post call.respond(
9 HttpStatusCode.Unauthorized,
10 ApiResponse.error<Unit>("Invalid credentials"),
11 )
12 call.respond(ApiResponse.ok(TokenResponse(token)))
13 }
14
15 post("/register") {
16 val request = call.receive<RegisterRequest>()
17 val user = authService.register(request)
18 call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
19 }
20
21 authenticate("jwt") {
22 get("/me") {
23 val userId = call.userId()
24 val user = authService.getProfile(userId)
25 call.respond(ApiResponse.ok(user))
26 }
27 }
28 }
29}
Status Pages (Error Handling)
kotlin
1// plugins/StatusPages.kt
2fun Application.configureStatusPages() {
3 install(StatusPages) {
4 exception<ContentTransformationException> { call, cause ->
5 call.respond(
6 HttpStatusCode.BadRequest,
7 ApiResponse.error<Unit>("Invalid request body: ${cause.message}"),
8 )
9 }
10
11 exception<IllegalArgumentException> { call, cause ->
12 call.respond(
13 HttpStatusCode.BadRequest,
14 ApiResponse.error<Unit>(cause.message ?: "Bad request"),
15 )
16 }
17
18 exception<AuthenticationException> { call, _ ->
19 call.respond(
20 HttpStatusCode.Unauthorized,
21 ApiResponse.error<Unit>("Authentication required"),
22 )
23 }
24
25 exception<AuthorizationException> { call, _ ->
26 call.respond(
27 HttpStatusCode.Forbidden,
28 ApiResponse.error<Unit>("Access denied"),
29 )
30 }
31
32 exception<NotFoundException> { call, cause ->
33 call.respond(
34 HttpStatusCode.NotFound,
35 ApiResponse.error<Unit>(cause.message ?: "Resource not found"),
36 )
37 }
38
39 exception<Throwable> { call, cause ->
40 call.application.log.error("Unhandled exception", cause)
41 call.respond(
42 HttpStatusCode.InternalServerError,
43 ApiResponse.error<Unit>("Internal server error"),
44 )
45 }
46
47 status(HttpStatusCode.NotFound) { call, status ->
48 call.respond(status, ApiResponse.error<Unit>("Route not found"))
49 }
50 }
51}
CORS Configuration
kotlin
1// plugins/CORS.kt
2fun Application.configureCORS() {
3 install(CORS) {
4 allowHost("localhost:3000")
5 allowHost("example.com", schemes = listOf("https"))
6 allowHeader(HttpHeaders.ContentType)
7 allowHeader(HttpHeaders.Authorization)
8 allowMethod(HttpMethod.Put)
9 allowMethod(HttpMethod.Delete)
10 allowMethod(HttpMethod.Patch)
11 allowCredentials = true
12 maxAgeInSeconds = 3600
13 }
14}
Koin Dependency Injection
Module Definition
kotlin
1// di/AppModule.kt
2val appModule = module {
3 // Database
4 single<Database> { DatabaseFactory.create(get()) }
5
6 // Repositories
7 single<UserRepository> { ExposedUserRepository(get()) }
8 single<OrderRepository> { ExposedOrderRepository(get()) }
9
10 // Services
11 single { UserService(get()) }
12 single { OrderService(get(), get()) }
13 single { AuthService(get(), get()) }
14}
15
16// Application setup
17fun Application.configureDI() {
18 install(Koin) {
19 modules(appModule)
20 }
21}
Using Koin in Routes
kotlin
1fun Route.userRoutes() {
2 val userService by inject<UserService>()
3
4 route("/users") {
5 get {
6 val users = userService.getAll()
7 call.respond(ApiResponse.ok(users))
8 }
9 }
10}
Koin for Testing
kotlin
1class UserServiceTest : FunSpec(), KoinTest {
2 override fun extensions() = listOf(KoinExtension(testModule))
3
4 private val testModule = module {
5 single<UserRepository> { mockk() }
6 single { UserService(get()) }
7 }
8
9 private val repository by inject<UserRepository>()
10 private val service by inject<UserService>()
11
12 init {
13 test("getUser returns user") {
14 coEvery { repository.findById("1") } returns testUser
15 service.getById("1") shouldBe testUser
16 }
17 }
18}
Request Validation
kotlin
1// Validate request data in routes
2fun Route.userRoutes() {
3 val userService by inject<UserService>()
4
5 post("/users") {
6 val request = call.receive<CreateUserRequest>()
7
8 // Validate
9 require(request.name.isNotBlank()) { "Name is required" }
10 require(request.name.length <= 100) { "Name must be 100 characters or less" }
11 require(request.email.matches(Regex(".+@.+\\..+"))) { "Invalid email format" }
12
13 val user = userService.create(request)
14 call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
15 }
16}
17
18// Or use a validation extension
19fun CreateUserRequest.validate() {
20 require(name.isNotBlank()) { "Name is required" }
21 require(name.length <= 100) { "Name must be 100 characters or less" }
22 require(email.matches(Regex(".+@.+\\..+"))) { "Invalid email format" }
23}
WebSockets
kotlin
1fun Application.configureWebSockets() {
2 install(WebSockets) {
3 pingPeriod = 15.seconds
4 timeout = 15.seconds
5 maxFrameSize = 64 * 1024 // 64 KiB — increase only if your protocol requires larger frames
6 masking = false // Server-to-client frames are unmasked per RFC 6455; client-to-server are always masked by Ktor
7 }
8}
9
10fun Route.chatRoutes() {
11 val connections = Collections.synchronizedSet<Connection>(LinkedHashSet())
12
13 webSocket("/chat") {
14 val thisConnection = Connection(this)
15 connections += thisConnection
16
17 try {
18 send("Connected! Users online: ${connections.size}")
19
20 for (frame in incoming) {
21 frame as? Frame.Text ?: continue
22 val text = frame.readText()
23 val message = ChatMessage(thisConnection.name, text)
24
25 // Snapshot under lock to avoid ConcurrentModificationException
26 val snapshot = synchronized(connections) { connections.toList() }
27 snapshot.forEach { conn ->
28 conn.session.send(Json.encodeToString(message))
29 }
30 }
31 } catch (e: Exception) {
32 logger.error("WebSocket error", e)
33 } finally {
34 connections -= thisConnection
35 }
36 }
37}
38
39data class Connection(val session: DefaultWebSocketSession) {
40 val name: String = "User-${counter.getAndIncrement()}"
41
42 companion object {
43 private val counter = AtomicInteger(0)
44 }
45}
testApplication Testing
Basic Route Testing
kotlin
1class UserRoutesTest : FunSpec({
2 test("GET /users returns list of users") {
3 testApplication {
4 application {
5 install(Koin) { modules(testModule) }
6 configureSerialization()
7 configureRouting()
8 }
9
10 val response = client.get("/users")
11
12 response.status shouldBe HttpStatusCode.OK
13 val body = response.body<ApiResponse<List<UserResponse>>>()
14 body.success shouldBe true
15 body.data.shouldNotBeNull().shouldNotBeEmpty()
16 }
17 }
18
19 test("POST /users creates a user") {
20 testApplication {
21 application {
22 install(Koin) { modules(testModule) }
23 configureSerialization()
24 configureStatusPages()
25 configureRouting()
26 }
27
28 val client = createClient {
29 install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) {
30 json()
31 }
32 }
33
34 val response = client.post("/users") {
35 contentType(ContentType.Application.Json)
36 setBody(CreateUserRequest("Alice", "alice@example.com"))
37 }
38
39 response.status shouldBe HttpStatusCode.Created
40 }
41 }
42
43 test("GET /users/{id} returns 404 for unknown id") {
44 testApplication {
45 application {
46 install(Koin) { modules(testModule) }
47 configureSerialization()
48 configureStatusPages()
49 configureRouting()
50 }
51
52 val response = client.get("/users/unknown-id")
53
54 response.status shouldBe HttpStatusCode.NotFound
55 }
56 }
57})
Testing Authenticated Routes
kotlin
1class AuthenticatedRoutesTest : FunSpec({
2 test("protected route requires JWT") {
3 testApplication {
4 application {
5 install(Koin) { modules(testModule) }
6 configureSerialization()
7 configureAuthentication()
8 configureRouting()
9 }
10
11 val response = client.post("/users") {
12 contentType(ContentType.Application.Json)
13 setBody(CreateUserRequest("Alice", "alice@example.com"))
14 }
15
16 response.status shouldBe HttpStatusCode.Unauthorized
17 }
18 }
19
20 test("protected route succeeds with valid JWT") {
21 testApplication {
22 application {
23 install(Koin) { modules(testModule) }
24 configureSerialization()
25 configureAuthentication()
26 configureRouting()
27 }
28
29 val token = generateTestJWT(userId = "test-user")
30
31 val client = createClient {
32 install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json() }
33 }
34
35 val response = client.post("/users") {
36 contentType(ContentType.Application.Json)
37 bearerAuth(token)
38 setBody(CreateUserRequest("Alice", "alice@example.com"))
39 }
40
41 response.status shouldBe HttpStatusCode.Created
42 }
43 }
44})
Configuration
application.yaml
yaml
1ktor:
2 application:
3 modules:
4 - com.example.ApplicationKt.module
5 deployment:
6 port: 8080
7
8jwt:
9 secret: ${JWT_SECRET}
10 issuer: "https://example.com"
11 audience: "https://example.com/api"
12 realm: "example"
13
14database:
15 url: ${DATABASE_URL}
16 driver: "org.postgresql.Driver"
17 maxPoolSize: 10
Reading Config
kotlin
1fun Application.configureDI() {
2 val dbUrl = environment.config.property("database.url").getString()
3 val dbDriver = environment.config.property("database.driver").getString()
4 val maxPoolSize = environment.config.property("database.maxPoolSize").getString().toInt()
5
6 install(Koin) {
7 modules(module {
8 single { DatabaseConfig(dbUrl, dbDriver, maxPoolSize) }
9 single { DatabaseFactory.create(get()) }
10 })
11 }
12}
Quick Reference: Ktor Patterns
| Pattern | Description |
|---|
route("/path") { get { } } | Route grouping with DSL |
call.receive<T>() | Deserialize request body |
call.respond(status, body) | Send response with status |
call.parameters["id"] | Read path parameters |
call.request.queryParameters["q"] | Read query parameters |
install(Plugin) { } | Install and configure plugin |
authenticate("name") { } | Protect routes with auth |
by inject<T>() | Koin dependency injection |
testApplication { } | Integration testing |
Remember: Ktor is designed around Kotlin coroutines and DSLs. Keep routes thin, push logic to services, and use Koin for dependency injection. Test with testApplication for full integration coverage.