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"]| Aspect | Ktor | Spring Boot | Vert.x |
|---|---|---|---|
| Design language | Kotlin-first | Java (Kotlin supported) | Java (Kotlin supported) |
| Concurrency | Coroutines | Thread-based + reactive | Event loop |
| Startup time | Very fast | Moderate | Fast |
| Memory footprint | Small | Medium-Large | Small |
| Ecosystem | Growing | Very rich | Rich |
| Learning curve | Moderate | Low (with Spring) | High |
| Best for | Microservices, modern APIs | Enterprise, monoliths | High-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 intofun Route.productRoutes(). Code is more organized and easier to test.- Centralized error handling — use
StatusPagesin Ktor or@RestControllerAdvicein 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@Validwith Bean Validation annotations.- Use
testApplicationfor Ktor testing — in-memory tests without a network port, faster and no need to mock the HTTP client. Spring Boot has@WebMvcTestfor a similar purpose.- Paginate from the start — endpoints returning lists always need pagination. Add
?page=1&size=10from the first endpoint, not after the data grows large.