Web Server #

Building a web server in Kotlin means choosing from three mature ecosystems: Ktor (Kotlin-native, coroutine-first, lightweight), Spring Boot (enterprise-grade, vast ecosystem, strong conventions), and Vert.x (reactive, high throughput, event-loop). All three can build reliable REST APIs, but they have different philosophies. This article covers all three in depth with complete REST API examples — routing, JSON serialization, middleware, validation, error handling — plus a guide to choosing the right framework for your needs.

Choosing a Framework #

flowchart TD
    A{Project Type?} --> B{Already in the\nSpring ecosystem?}
    B -- Yes --> C["Spring Boot\nSeamless integration, built-in DI"]
    B -- No --> D{Main priority?}
    D -- "Kotlin-idiomatic\nlightweight, coroutines" --> E["Ktor\nA perfect fit for Kotlin"]
    D -- "Very high throughput\nreactive" --> F["Vert.x\nEvent-loop, non-blocking"]
    D -- "Fast startup\nGraalVM native" --> G["Quarkus / Micronaut\nCloud-native optimized"]
AspectKtorSpring BootVert.x
Design languageKotlin-firstJava (Kotlin supported)Java (Kotlin supported)
ConcurrencyCoroutinesThread-based + reactiveEvent loop
Startup timeVery fastModerateFast
Memory footprintSmallMedium-LargeSmall
EcosystemGrowingVery richRich
Learning curveModerateLow (with Spring)High
Best forMicroservices, modern APIsEnterprise, monolithsHigh-throughput APIs

REST API with Ktor #

Ktor uses plugins (formerly called features) that are installed explicitly — you only install what you need. This keeps applications lightweight.

Dependencies #

// build.gradle.kts
dependencies {
    val ktorVersion = "2.3.9"
    implementation("io.ktor:ktor-server-core:$ktorVersion")
    implementation("io.ktor:ktor-server-netty:$ktorVersion")
    implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
    implementation("io.ktor:ktor-server-status-pages:$ktorVersion")
    implementation("io.ktor:ktor-server-call-logging:$ktorVersion")
    implementation("io.ktor:ktor-server-auth:$ktorVersion")
    implementation("io.ktor:ktor-server-auth-jwt:$ktorVersion")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
    implementation("ch.qos.logback:logback-classic:1.5.3")

    testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
    testImplementation("org.jetbrains.kotlin:kotlin-test")
}

// Add to the plugins section
plugins {
    kotlin("plugin.serialization") version "2.0.0"
}

Data Models #

import kotlinx.serialization.Serializable

@Serializable
data class Product(
    val id: Int,
    val name: String,
    val price: Double,
    val stock: Int,
    val category: String
)

@Serializable
data class NewProduct(
    val name: String,
    val price: Double,
    val stock: Int,
    val category: String
)

@Serializable
data class ErrorResponse(
    val code: Int,
    val message: String
)

@Serializable
data class PaginatedResponse<T>(
    val data: List<T>,
    val total: Int,
    val page: Int,
    val pageSize: Int
)

A Complete Ktor Application #

import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.json.Json
import org.slf4j.event.Level

// Simulated in-memory database
object ProductRepo {
    private val data = mutableListOf(
        Product(1, "Gaming Laptop", 15_000_000.0, 10, "Electronics"),
        Product(2, "Wireless Mouse", 250_000.0, 50, "Electronics"),
        Product(3, "Desk", 1_500_000.0, 8, "Furniture")
    )
    private var nextId = 4

    fun getAll(category: String? = null, page: Int = 1, size: Int = 10): PaginatedResponse<Product> {
        val filtered = if (category != null) data.filter { it.category == category } else data.toList()
        val start = (page - 1) * size
        val result = filtered.drop(start).take(size)
        return PaginatedResponse(result, filtered.size, page, size)
    }

    fun getById(id: Int): Product? = data.find { it.id == id }

    fun add(new: NewProduct): Product {
        val product = Product(nextId++, new.name, new.price, new.stock, new.category)
        data.add(product)
        return product
    }

    fun update(id: Int, new: NewProduct): Product? {
        val index = data.indexOfFirst { it.id == id }
        if (index == -1) return null
        val updated = Product(id, new.name, new.price, new.stock, new.category)
        data[index] = updated
        return updated
    }

    fun delete(id: Int): Boolean = data.removeIf { it.id == id }
}

fun Application.configure() {
    // Plugin: JSON serialization
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = true
            isLenient = true
            ignoreUnknownKeys = true
        })
    }

    // Plugin: Request logging
    install(CallLogging) {
        level = Level.INFO
        filter { call -> call.request.path().startsWith("/api") }
    }

    // Plugin: Centralized error handling
    install(StatusPages) {
        exception<IllegalArgumentException> { call, cause ->
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, cause.message ?: "Invalid input"))
        }
        exception<NoSuchElementException> { call, cause ->
            call.respond(HttpStatusCode.NotFound, ErrorResponse(404, cause.message ?: "Not found"))
        }
        exception<Throwable> { call, cause ->
            call.application.log.error("Unhandled error", cause)
            call.respond(HttpStatusCode.InternalServerError, ErrorResponse(500, "Internal server error"))
        }
        status(HttpStatusCode.NotFound) { call, _ ->
            call.respond(HttpStatusCode.NotFound, ErrorResponse(404, "Endpoint not found"))
        }
    }

    // Routing
    routing {
        route("/api/v1") {
            productRoutes()
        }

        // Health check endpoint
        get("/health") {
            call.respond(mapOf("status" to "ok", "time" to System.currentTimeMillis()))
        }
    }
}

fun Route.productRoutes() {
    route("/products") {
        // GET /api/v1/products?category=Electronics&page=1&size=10
        get {
            val category = call.request.queryParameters["category"]
            val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
            val size = call.request.queryParameters["size"]?.toIntOrNull() ?: 10

            require(page > 0) { "Page must be greater than 0" }
            require(size in 1..100) { "Page size must be between 1 and 100" }

            val result = ProductRepo.getAll(category, page, size)
            call.respond(result)
        }

        // GET /api/v1/products/{id}
        get("{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: throw IllegalArgumentException("ID must be a number")

            val product = ProductRepo.getById(id)
                ?: throw NoSuchElementException("Product with ID $id not found")

            call.respond(product)
        }

        // POST /api/v1/products
        post {
            val new = runCatching { call.receive<NewProduct>() }
                .getOrElse { throw IllegalArgumentException("Invalid request body: ${it.message}") }

            require(new.name.isNotBlank()) { "Product name must not be empty" }
            require(new.price > 0) { "Price must be greater than 0" }
            require(new.stock >= 0) { "Stock must not be negative" }

            val product = ProductRepo.add(new)
            call.respond(HttpStatusCode.Created, product)
        }

        // PUT /api/v1/products/{id}
        put("{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: throw IllegalArgumentException("ID must be a number")

            val new = runCatching { call.receive<NewProduct>() }
                .getOrElse { throw IllegalArgumentException("Invalid request body") }

            val updated = ProductRepo.update(id, new)
                ?: throw NoSuchElementException("Product with ID $id not found")

            call.respond(updated)
        }

        // DELETE /api/v1/products/{id}
        delete("{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: throw IllegalArgumentException("ID must be a number")

            val success = ProductRepo.delete(id)
            if (!success) throw NoSuchElementException("Product with ID $id not found")

            call.respond(HttpStatusCode.NoContent)
        }
    }
}

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        configure()
    }.start(wait = true)
}

Middleware in Ktor #

Middleware in Ktor is implemented as an ApplicationPlugin or with intercept:

// Simple middleware: add a header to every response
fun Application.installCustomHeaders() {
    intercept(ApplicationCallPipeline.Plugins) {
        proceed()
        call.response.headers.append("X-Powered-By", "Ktor/Kotlin")
        call.response.headers.append("X-Request-Id", java.util.UUID.randomUUID().toString())
    }
}

// Simple rate limiting (per IP)
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger

val requestCounts = ConcurrentHashMap<String, AtomicInteger>()

fun Application.installRateLimiting(max: Int = 100) {
    intercept(ApplicationCallPipeline.Plugins) {
        val ip = call.request.local.remoteAddress
        val count = requestCounts.getOrPut(ip) { AtomicInteger(0) }

        if (count.incrementAndGet() > max) {
            call.respond(HttpStatusCode.TooManyRequests, ErrorResponse(429, "Too many requests"))
            finish()
        }
    }
}

REST API with Spring Boot #

Spring Boot excels in the enterprise ecosystem with dependency injection, auto-configuration, and integration with databases, security, and observability.

// build.gradle.kts
plugins {
    kotlin("jvm") version "2.0.0"
    kotlin("plugin.spring") version "2.0.0"
    id("org.springframework.boot") version "3.2.4"
    id("io.spring.dependency-management") version "1.1.4"
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.*
import jakarta.validation.Valid
import jakarta.validation.constraints.*

@SpringBootApplication
class WebApplication

fun main(args: Array<String>) {
    runApplication<WebApplication>(*args)
}

// Model with validation
data class ProductDto(
    @field:NotBlank(message = "Name must not be empty")
    val name: String,

    @field:Positive(message = "Price must be positive")
    val price: Double,

    @field:PositiveOrZero(message = "Stock must not be negative")
    val stock: Int,

    @field:NotBlank(message = "Category must not be empty")
    val category: String
)

data class ProductResponse(
    val id: Int,
    val name: String,
    val price: Double,
    val stock: Int,
    val category: String
)

// Service layer
@Service
class ProductService {
    private val data = mutableListOf(
        ProductResponse(1, "Gaming Laptop", 15_000_000.0, 10, "Electronics"),
        ProductResponse(2, "Wireless Mouse", 250_000.0, 50, "Electronics")
    )
    private var nextId = 3

    fun getAll(category: String?): List<ProductResponse> =
        if (category != null) data.filter { it.category == category } else data.toList()

    fun getById(id: Int): ProductResponse =
        data.find { it.id == id } ?: throw NoSuchElementException("Product $id not found")

    fun add(dto: ProductDto): ProductResponse {
        val product = ProductResponse(nextId++, dto.name, dto.price, dto.stock, dto.category)
        data.add(product)
        return product
    }

    fun delete(id: Int) {
        val success = data.removeIf { it.id == id }
        if (!success) throw NoSuchElementException("Product $id not found")
    }
}

// Controller
@RestController
@RequestMapping("/api/v1/products")
@Validated
class ProductController(private val service: ProductService) {

    @GetMapping
    fun getAll(@RequestParam(required = false) category: String?): List<ProductResponse> =
        service.getAll(category)

    @GetMapping("/{id}")
    fun getById(@PathVariable id: Int): ProductResponse =
        service.getById(id)

    @PostMapping
    fun add(@Valid @RequestBody dto: ProductDto): ResponseEntity<ProductResponse> {
        val product = service.add(dto)
        return ResponseEntity.status(HttpStatus.CREATED).body(product)
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    fun delete(@PathVariable id: Int) {
        service.delete(id)
    }
}

// Global exception handler
@RestControllerAdvice
class ErrorHandler {

    @ExceptionHandler(NoSuchElementException::class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    fun handleNotFound(e: NoSuchElementException) =
        mapOf("code" to 404, "message" to (e.message ?: "Not found"))

    @ExceptionHandler(Exception::class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    fun handleError(e: Exception) =
        mapOf("code" to 500, "message" to "Internal server error")
}

REST API with Vert.x #

Vert.x uses an event loop model — one thread handles many requests non-blocking, very efficient for high throughput:

import io.vertx.core.AbstractVerticle
import io.vertx.core.Vertx
import io.vertx.core.json.Json
import io.vertx.core.json.JsonObject
import io.vertx.ext.web.Router
import io.vertx.ext.web.handler.BodyHandler

class ApiVerticle : AbstractVerticle() {
    private val products = mutableListOf(
        JsonObject().put("id", 1).put("name", "Laptop").put("price", 15_000_000),
        JsonObject().put("id", 2).put("name", "Mouse").put("price", 250_000)
    )
    private var nextId = 3

    override fun start() {
        val router = Router.router(vertx)

        // Middleware: parse the body for POST/PUT
        router.route().handler(BodyHandler.create())

        // Middleware: CORS
        router.route().handler { ctx ->
            ctx.response()
                .putHeader("Access-Control-Allow-Origin", "*")
                .putHeader("Content-Type", "application/json")
            ctx.next()
        }

        // Routes
        router.get("/api/products").handler { ctx ->
            ctx.response().end(Json.encode(products))
        }

        router.get("/api/products/:id").handler { ctx ->
            val id = ctx.pathParam("id").toIntOrNull()
            if (id == null) {
                ctx.response().setStatusCode(400).end("""{"message":"Invalid ID"}""")
                return@handler
            }
            val item = products.find { it.getInteger("id") == id }
            if (item == null) {
                ctx.response().setStatusCode(404).end("""{"message":"Product not found"}""")
            } else {
                ctx.response().end(item.encode())
            }
        }

        router.post("/api/products").handler { ctx ->
            val body = ctx.body().asJsonObject()
            val created = JsonObject()
                .put("id", nextId++)
                .put("name", body.getString("name", ""))
                .put("price", body.getDouble("price", 0.0))
            products.add(created)
            ctx.response().setStatusCode(201).end(created.encode())
        }

        router.delete("/api/products/:id").handler { ctx ->
            val id = ctx.pathParam("id").toIntOrNull()
            val success = products.removeIf { it.getInteger("id") == id }
            if (success) {
                ctx.response().setStatusCode(204).end()
            } else {
                ctx.response().setStatusCode(404).end("""{"message":"Product not found"}""")
            }
        }

        vertx.createHttpServer()
            .requestHandler(router)
            .listen(8080) { result ->
                if (result.succeeded()) println("Vert.x API server on port 8080")
                else println("Failed: ${result.cause().message}")
            }
    }
}

fun main() {
    Vertx.vertx().deployVerticle(ApiVerticle())
}

Testing REST APIs in Ktor #

Ktor provides testApplication, which enables in-memory testing without opening a network port:

import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.*

class ProductApiTest {

    @Test
    fun `GET products returns a list`() = testApplication {
        application { configure() }

        val response = client.get("/api/v1/products")
        assertEquals(HttpStatusCode.OK, response.status)
        assertTrue(response.bodyAsText().contains("\"data\""))
    }

    @Test
    fun `GET product with a valid ID returns the product`() = testApplication {
        application { configure() }

        val response = client.get("/api/v1/products/1")
        assertEquals(HttpStatusCode.OK, response.status)
        assertTrue(response.bodyAsText().contains("\"id\":1"))
    }

    @Test
    fun `GET product with a non-existent ID returns 404`() = testApplication {
        application { configure() }

        val response = client.get("/api/v1/products/9999")
        assertEquals(HttpStatusCode.NotFound, response.status)
    }

    @Test
    fun `POST a new product succeeds`() = testApplication {
        application { configure() }

        val response = client.post("/api/v1/products") {
            contentType(ContentType.Application.Json)
            setBody("""{"name":"Keyboard","price":500000,"stock":20,"category":"Electronics"}""")
        }
        assertEquals(HttpStatusCode.Created, response.status)
        assertTrue(response.bodyAsText().contains("Keyboard"))
    }
}

Summary #

  • Ktor for Kotlin-native projects — coroutines as the primary primitive, a modular plugin system (install only what you need), very fast startup. The best choice for new microservices written from scratch in Kotlin.
  • Spring Boot for the enterprise ecosystem — auto-configuration, dependency injection with @Autowired/constructors, first-class integration with Spring Security, Spring Data, Actuator. Choose it if the team is already familiar or the project needs a very rich ecosystem.
  • Vert.x for very high throughput — a non-blocking event loop like Node.js but on the JVM. Suitable for API gateways, proxies, or services that need to handle hundreds of thousands of concurrent connections.
  • Split routes into extension functions — instead of all routes in one routing {} block, extract them into fun Route.productRoutes(). Code is more organized and easier to test.
  • Centralized error handling — use StatusPages in Ktor or @RestControllerAdvice in Spring Boot. Don’t handle exceptions one by one in every handler.
  • Validate input in the controller, not the service — reject invalid input early. In Ktor use require(), in Spring Boot use @Valid with Bean Validation annotations.
  • Use testApplication for Ktor testing — in-memory tests without a network port, faster and no need to mock the HTTP client. Spring Boot has @WebMvcTest for a similar purpose.
  • Paginate from the start — endpoints returning lists always need pagination. Add ?page=1&size=10 from the first endpoint, not after the data grows large.

← Previous: WebSocket   Next: Unit Testing →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact