Ktor Lightweight APIs
Where Spring Boot (Module 08) is a batteries-included enterprise framework, Ktor is a toolkit: you pick the pieces you need and nothing more. Built by JetBrains — the same team behind Kotlin — Ktor is coroutine-first and DSL-driven. If you have used Express.js in TypeScript or Echo/Chi/Gin in Go, it will feel immediately familiar. We rebuild the same task management API from Module 08 here, so you get a direct side-by-side comparison of the two frameworks.
What Is Ktor?
Section titled “What Is Ktor?”Ktor is a lightweight, asynchronous web framework designed from the ground up for Kotlin and coroutines. It is a toolkit, not a framework: you assemble your stack from individual libraries.
Mental model mapping
Section titled “Mental model mapping”| Concept | Express.js (TS) | Go (Echo/Chi) | Spring Boot (Kotlin) | Ktor (Kotlin) |
|---|---|---|---|---|
| Philosophy | Minimal, middleware-based | Minimal, stdlib + router | Opinionated, batteries-included | Minimal, plugin-based |
| Server | Node.js runtime | Go runtime | Embedded Tomcat/Netty | Embedded Netty/CIO/Jetty |
| Routing | app.get("/path", handler) | e.GET("/path", handler) | @GetMapping("/path") | get("/path") { } DSL |
| Middleware | app.use(fn) | e.Use(fn) | Filters / Interceptors | install(Plugin) |
| DI | tsyringe / manual | wire / manual | Built-in IoC container | Koin / Kodein / manual |
| JSON | Built-in JSON.parse | encoding/json | Jackson (auto-configured) | kotlinx.serialization |
| Async model | Event loop (single thread) | Goroutines | Virtual threads / Coroutines | Coroutines (native) |
| Config | dotenv / config | envconfig / Viper | application.yml | application.conf (HOCON) |
| Testing | Jest + supertest | testing + httptest | MockMvc / WebTestClient | testApplication { } DSL |
| Startup time | ~100ms | ~10ms | ~1-3s | ~100-300ms |
| Memory | ~30-80MB | ~10-30MB | ~100-300MB | ~30-80MB |
Why Ktor?
Section titled “Why Ktor?”- Kotlin-native: No Java legacy, no annotation magic, no reflection at runtime.
- Coroutine-first: Every handler is a
suspendfunction — no blocking threads. - Lightweight: Only include what you need. No classpath scanning, no proxy generation.
- DSL-driven: Routes and configuration are Kotlin code, not annotations.
- Fast startup: 100-300ms vs. 1-3 seconds for Spring Boot.
- Low memory: 30-80MB vs. 100-300MB for Spring Boot.
- Multiplatform potential: the Ktor client works on JVM, JS, and Native.
Project Setup
Section titled “Project Setup”The fastest way to start is start.ktor.io (build system Gradle Kotlin, Ktor version 3.1.x, engine Netty, plugins Content Negotiation, kotlinx.serialization, Status Pages, Call Logging). Below we build it manually so you understand every piece.
build.gradle.kts
Section titled “build.gradle.kts”plugins { kotlin("jvm") version "2.1.0" kotlin("plugin.serialization") version "2.1.0" // For kotlinx.serialization id("io.ktor.plugin") version "3.1.0" // Ktor Gradle plugin}
group = "com.example"version = "1.0.0"
application { mainClass.set("com.example.taskapi.ApplicationKt")}
java { toolchain { languageVersion = JavaLanguageVersion.of(21) }}
repositories { mavenCentral()}
val ktorVersion = "3.1.0"val koinVersion = "4.0.2"val logbackVersion = "1.5.15"
dependencies { // Ktor server — pick individual features implementation("io.ktor:ktor-server-core:$ktorVersion") implementation("io.ktor:ktor-server-netty:$ktorVersion") // Netty engine implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion") implementation("io.ktor:ktor-server-status-pages:$ktorVersion") implementation("io.ktor:ktor-server-cors:$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("io.ktor:ktor-server-request-validation:$ktorVersion")
// Serialization implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
// Koin for DI implementation("io.insert-koin:koin-ktor:$koinVersion") implementation("io.insert-koin:koin-logger-slf4j:$koinVersion")
// Logging implementation("ch.qos.logback:logback-classic:$logbackVersion")
// Testing testImplementation("io.ktor:ktor-server-test-host:$ktorVersion") testImplementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("io.insert-koin:koin-test:$koinVersion") testRuntimeOnly("org.junit.platform:junit-platform-launcher")}
tasks.withType<Test> { useJUnitPlatform()}Key differences from Spring Boot’s build.gradle.kts:
| Spring Boot | Ktor | Why |
|---|---|---|
kotlin("plugin.spring") | kotlin("plugin.serialization") | Spring needs open classes; Ktor needs serialization codegen |
org.springframework.boot plugin | io.ktor.plugin | Framework-specific Gradle plugins |
spring-boot-starter-web (one dep) | Multiple ktor-server-* deps | Ktor: pick individual features |
jackson-module-kotlin | ktor-serialization-kotlinx-json | Different JSON libraries |
kotlin-reflect (required) | Not needed | Ktor avoids reflection |
The dependency philosophy mirrors picking your middleware in Express or Go:
// Express.js — pick your middleware{ "dependencies": { "express": "^4.18.0", // ≈ ktor-server-core + ktor-server-netty "cors": "^2.8.5", // ≈ ktor-server-cors "morgan": "^1.10.0", // ≈ ktor-server-call-logging "jsonwebtoken": "^9.0.0" // ≈ ktor-server-auth-jwt }}// Echo — also pick-your-middleware (go.mod)require ( github.com/labstack/echo/v4 v4.11.0 // ≈ ktor-server-core + engine github.com/golang-jwt/jwt/v5 v5.0.0 // ≈ ktor-server-auth-jwt)The project name lives in one line:
rootProject.name = "task-api-ktor"Entry point: embeddedServer vs EngineMain
Section titled “Entry point: embeddedServer vs EngineMain”Ktor offers two ways to start a server. embeddedServer is programmatic — best for
learning and tests. EngineMain is configuration-driven — best for production.
// Option 1: embeddedServer (programmatic)fun main() { embeddedServer(Netty, port = 8080) { configureRouting() configureSerialization() configureErrorHandling() }.start(wait = true)}// Option 2: EngineMain (configuration-driven)fun main(args: Array<String>) { EngineMain.main(args)}
fun Application.module() { configureRouting() configureSerialization() configureErrorHandling()}EngineMain reads a HOCON config file, similar to @SpringBootApplication reading
application.yml:
ktor { deployment { port = 8080 port = ${?PORT} # Override with PORT env variable } application { modules = [ com.example.taskapi.ApplicationKt.module ] }}The embeddedServer form maps directly onto how you’d bootstrap Express or Echo:
// Expressconst app = express();app.use(express.json()); // ≈ configureSerialization()app.use(errorHandler); // ≈ configureErrorHandling()app.use("/api", router); // ≈ configureRouting()app.listen(8080); // ≈ .start(wait = true)// Echoe := echo.New()e.Use(middleware.Logger()) // ≈ configureCallLogging()e.Use(middleware.Recover()) // ≈ configureErrorHandling()setupRoutes(e) // ≈ configureRouting()e.Start(":8080") // ≈ .start(wait = true)When to use which:
| Use Case | embeddedServer | EngineMain |
|---|---|---|
| Quick prototypes | Yes | |
| Tests | Yes | |
| Full control over startup | Yes | |
| External configuration (port, modules) | Yes | |
| Production deployments | Yes | |
| Multiple environments | Yes |
Project directory structure
Section titled “Project directory structure”Directorysrc/
Directorymain/
Directorykotlin/com/example/taskapi/
- Application.kt entry point + module wiring
Directoryroutes/
- TaskRoutes.kt route definitions (≈ controllers)
Directorymodels/
- Task.kt domain models + DTOs
Directoryrepository/
- TaskRepository.kt data access
Directoryplugins/
- Serialization.kt plugin configuration
- ErrorHandling.kt StatusPages config
- RequestValidation.kt request validation
Directorydi/
- AppModule.kt Koin DI modules
Directoryresources/
- application.conf HOCON configuration
- logback.xml logging configuration
Directorytest/kotlin/com/example/taskapi/
- TaskRoutesTest.kt
Compared to the Spring Boot structure:
| Spring Boot | Ktor | Notes |
|---|---|---|
controller/TaskController.kt | routes/TaskRoutes.kt | Annotations vs. DSL functions |
service/TaskService.kt | service/TaskService.kt | Same concept, no @Service |
model/Task.kt | models/Task.kt | @Serializable vs. Jackson |
config/AppConfig.kt | plugins/Serialization.kt | @Configuration vs. install() |
exception/GlobalExceptionHandler.kt | plugins/ErrorHandling.kt | @ControllerAdvice vs. StatusPages |
Running the application
Section titled “Running the application”./gradlew run # run directly
./gradlew buildFatJar # build a fat JARjava -jar build/libs/task-api-ktor-all.jar
./gradlew buildImage # build a Docker image (Ktor plugin provides this)Routing
Section titled “Routing”Ktor uses a Kotlin DSL for routing instead of annotations. A route handler is a
get("/path") { } block; you wire route files into routing { }. If you have used
Express.js or Echo/Chi in Go, this will feel natural.
The simplest route
Section titled “The simplest route”fun Route.helloRoutes() { get("/hello") { call.respondText("Hello, World!") }
get("/hello/json") { call.respond(mapOf("message" to "Hello, World!")) }}Wire it up with routing { }:
fun Application.configureRouting() { routing { helloRoutes() }}The same two endpoints across frameworks:
// Express.js — almost identical structureconst router = express.Router();
router.get("/hello", (req, res) => { res.send("Hello, World!");});
router.get("/hello/json", (req, res) => { res.json({ message: "Hello, World!" });});
app.use(router);// Echo — also very similare.GET("/hello", func(c echo.Context) error { return c.String(http.StatusOK, "Hello, World!")})
e.GET("/hello/json", func(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"message": "Hello, World!"})})// Spring Boot — annotation-based, different paradigm@RestControllerclass HelloController { @GetMapping("/hello") fun hello(): String = "Hello, World!"
@GetMapping("/hello/json") fun helloJson(): Map<String, String> = mapOf("message" to "Hello, World!")}Route nesting
Section titled “Route nesting”The route("/api/tasks") { } block groups sub-routes under a common path:
fun Route.taskRoutes() { route("/api/tasks") { get { /* GET /api/tasks — list all */ } get("/{id}") { /* GET /api/tasks/{id} — get by ID */ } post { /* POST /api/tasks — create */ } put("/{id}") { /* PUT /api/tasks/{id} — update */ } delete("/{id}") { /* DELETE /api/tasks/{id} — delete */ }
// Nested sub-routes route("/status") { get("/{status}") { /* GET /api/tasks/status/{status} */ } } get("/search") { /* GET /api/tasks/search?q=term */ } }}const router = express.Router();router.get("/", listTasks);router.get("/:id", getTask);router.post("/", createTask);router.put("/:id", updateTask);router.delete("/:id", deleteTask);router.get("/status/:status", getByStatus);router.get("/search", searchTasks);
app.use("/api/tasks", router);// chir.Route("/api/tasks", func(r chi.Router) { r.Get("/", listTasks) r.Get("/{id}", getTask) r.Post("/", createTask) r.Put("/{id}", updateTask) r.Delete("/{id}", deleteTask) r.Get("/status/{status}", getByStatus) r.Get("/search", searchTasks)})Path parameters
Section titled “Path parameters”Read path params from call.parameters["id"]. The ?: elvis operator handles the
missing case:
get("/{id}") { val id = call.parameters["id"] ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing id") call.respond(taskService.findById(id))}
// Multiple path parametersget("/users/{userId}/tasks/{taskId}") { val userId = call.parameters["userId"]!! val taskId = call.parameters["taskId"]!!}// Expressrouter.get("/:id", (req, res) => { const id = req.params.id; // ≈ call.parameters["id"]});// Echoe.GET("/:id", func(c echo.Context) error { id := c.Param("id") // ≈ call.parameters["id"] return nil})// Spring Boot@GetMapping("/{id}")fun getTask(@PathVariable id: String) // ≈ call.parameters["id"]Query parameters
Section titled “Query parameters”Read query params from call.request.queryParameters["q"], chaining
?.toIntOrNull() ?: 0 for typed defaults:
get("/search") { val query = call.request.queryParameters["q"] ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing query parameter 'q'")
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 0 val size = call.request.queryParameters["size"]?.toIntOrNull() ?: 20
call.respond(taskService.search(query))}// Expressrouter.get("/search", (req, res) => { const q = req.query.q; // ≈ queryParameters["q"] const page = parseInt(req.query.page) || 0; // ≈ ?.toIntOrNull() ?: 0});// Gofunc searchTasks(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") // ≈ queryParameters["q"] page, _ := strconv.Atoi(r.URL.Query().Get("page"))}// Spring Boot@GetMapping("/search")fun searchTasks( @RequestParam q: String, // ≈ queryParameters["q"] @RequestParam(defaultValue = "0") page: Int)The full CRUD route file
Section titled “The full CRUD route file”This mirrors the Spring Boot TaskController from Module 08. The repository is
injected via Koin’s by inject<TaskRepository>() (covered below):
fun Route.taskRoutes() { val repository by inject<TaskRepository>()
route("/api/tasks") {
// GET /api/tasks — list all tasks get { val tasks = repository.findAll().map { it.toResponse() } call.respond(tasks) }
// GET /api/tasks/search?q=term get("/search") { val query = call.request.queryParameters["q"] ?: return@get call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Missing 'q' parameter")) call.respond(repository.search(query).map { it.toResponse() }) }
// GET /api/tasks/{id} get("/{id}") { val id = call.parameters["id"]!! val task = repository.findById(id) ?: return@get call.respond( HttpStatusCode.NotFound, mapOf("error" to "Not Found", "message" to "Task not found: $id") ) call.respond(task.toResponse()) }
// POST /api/tasks post { val request = call.receive<CreateTaskRequest>() val task = Task( title = request.title, description = request.description, priority = Priority.valueOf(request.priority) ) val saved = repository.save(task) call.response.header("Location", "/api/tasks/${saved.id}") call.respond(HttpStatusCode.Created, saved.toResponse()) }
// PUT /api/tasks/{id} put("/{id}") { val id = call.parameters["id"]!! val existing = repository.findById(id) ?: return@put call.respond( HttpStatusCode.NotFound, mapOf("error" to "Not Found", "message" to "Task not found: $id") ) val request = call.receive<UpdateTaskRequest>() val updated = existing.copy( title = request.title ?: existing.title, description = request.description ?: existing.description, status = request.status?.let { TaskStatus.valueOf(it.uppercase()) } ?: existing.status, priority = request.priority?.let { Priority.valueOf(it.uppercase()) } ?: existing.priority, updatedAt = Instant.now() ) call.respond(repository.save(updated).toResponse()) }
// DELETE /api/tasks/{id} delete("/{id}") { val id = call.parameters["id"]!! if (!repository.deleteById(id)) { return@delete call.respond( HttpStatusCode.NotFound, mapOf("error" to "Not Found", "message" to "Task not found: $id") ) } call.respond(HttpStatusCode.NoContent) } }}Notice that every route handler is a suspend lambda. Ktor runs them on coroutine
dispatchers, so they never block a thread — this is equivalent to async/await in
TypeScript or goroutines in Go. Concurrency is built into the framework.
Plugins (Middleware)
Section titled “Plugins (Middleware)”In Ktor, middleware is called plugins. You install them on the application or on
specific routes with install(Plugin) { }. This is conceptually identical to
app.use() in Express or r.Use() in Go chi.
A request flows through installed plugins before and after hitting your route handler:
flowchart LR REQ["HTTP Request"] --> CL["CallLogging"] CL --> CN["ContentNegotiation"] CN --> CORS["CORS"] CORS --> SP["StatusPages"] SP --> H["Route handler (suspend)"] H --> RESP["HTTP Response"]
The installation pattern
Section titled “The installation pattern”fun Application.module() { install(ContentNegotiation) { // ≈ app.use(express.json()) json() } install(StatusPages) { // ≈ app.use(errorHandler) // error handlers } install(CallLogging) { // ≈ app.use(morgan("dev")) level = Level.INFO } install(CORS) { // ≈ app.use(cors({...})) // CORS config }}const app = express();app.use(express.json()); // ≈ install(ContentNegotiation)app.use(morgan("dev")); // ≈ install(CallLogging)app.use(cors({ origin: "*" })); // ≈ install(CORS)app.use(errorHandler); // ≈ install(StatusPages)e := echo.New()e.Use(middleware.Logger()) // ≈ install(CallLogging)e.Use(middleware.Recover()) // ≈ install(StatusPages)e.Use(middleware.CORSWithConfig(config)) // ≈ install(CORS)ContentNegotiation with kotlinx.serialization
Section titled “ContentNegotiation with kotlinx.serialization”This plugin handles automatic JSON serialization and deserialization:
fun Application.configureSerialization() { install(ContentNegotiation) { json(Json { prettyPrint = true // Readable JSON in development isLenient = false // Strict JSON parsing ignoreUnknownKeys = true // Ignore extra fields in requests encodeDefaults = true // Include default values in responses }) }}After installing it, call.respond(myObject) automatically serializes to JSON and
call.receive<MyType>() automatically deserializes. That’s equivalent to
app.use(express.json()) + res.json(obj) in Express, the built-in c.JSON() /
c.Bind() in Go Echo, or auto-configured Jackson in Spring Boot (zero setup).
StatusPages for error handling
Section titled “StatusPages for error handling”StatusPages is Ktor’s equivalent of Spring Boot’s @ControllerAdvice /
@ExceptionHandler — centralized error handling via a lambda DSL:
@Serializabledata class ErrorResponse(val status: Int, val error: String, val message: String)
fun Application.configureErrorHandling() { install(StatusPages) { // Handle specific exception types exception<TaskNotFoundException> { call, cause -> call.respond( HttpStatusCode.NotFound, ErrorResponse(404, "Not Found", cause.message ?: "Resource not found") ) }
exception<IllegalArgumentException> { call, cause -> call.respond( HttpStatusCode.BadRequest, ErrorResponse(400, "Bad Request", cause.message ?: "Invalid request") ) }
// Catch-all for unexpected exceptions exception<Throwable> { call, cause -> call.application.environment.log.error("Unhandled exception", cause) call.respond( HttpStatusCode.InternalServerError, ErrorResponse(500, "Internal Server Error", "An unexpected error occurred") ) }
// Handle status codes without exceptions status(HttpStatusCode.NotFound) { call, status -> call.respond(status, ErrorResponse(404, "Not Found", "The requested resource was not found")) } }}
class TaskNotFoundException(val taskId: String) : RuntimeException("Task not found: $taskId")Spring uses annotations and reflection; Ktor uses a lambda DSL. The same idea expressed in Express and Go:
// Express error middlewareapp.use((err: Error, req: Request, res: Response, next: NextFunction) => { if (err instanceof TaskNotFoundError) { return res.status(404).json({ error: "Not Found", message: err.message }); } res.status(500).json({ error: "Internal Server Error" });});// Echo error handlere.HTTPErrorHandler = func(err error, c echo.Context) { var taskErr *TaskNotFoundError if errors.As(err, &taskErr) { c.JSON(http.StatusNotFound, ErrorResponse{Error: "Not Found", Message: err.Error()}) return } c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal Server Error"})}// Spring Boot — annotation-based exception handling@RestControllerAdviceclass GlobalExceptionHandler { @ExceptionHandler(TaskNotFoundException::class) fun handleNotFound(ex: TaskNotFoundException): ResponseEntity<ErrorResponse> { return ResponseEntity.status(HttpStatus.NOT_FOUND).body( ErrorResponse(404, "Not Found", ex.message ?: "Resource not found") ) }}CORS and CallLogging
Section titled “CORS and CallLogging”CORS is configured declaratively in the install(CORS) { } block:
fun Application.configureCORS() { install(CORS) { allowHost("localhost:3000") allowHost("myapp.com", schemes = listOf("https")) allowMethod(HttpMethod.Put) allowMethod(HttpMethod.Delete) allowHeader(HttpHeaders.ContentType) allowHeader(HttpHeaders.Authorization) allowCredentials = true maxAgeInSeconds = 3600 }}That’s the equivalent of app.use(cors({ origin: [...], methods: [...], credentials: true }))
in Express. CallLogging is Ktor’s morgan:
fun Application.configureCallLogging() { install(CallLogging) { level = org.slf4j.event.Level.INFO filter { call -> call.request.path().startsWith("/api") } format { call -> val method = call.request.httpMethod.value val path = call.request.path() "$method $path -> ${call.response.status()}" } }}Custom plugins
Section titled “Custom plugins”Ktor lets you write custom plugins with lifecycle hooks — no class inheritance, no
annotation. Here is a request-timing plugin that adds an X-Response-Time header:
val ResponseTimingPlugin = createApplicationPlugin(name = "ResponseTiming") { onCall { call -> call.attributes.put(startTimeKey, System.currentTimeMillis()) } onCallRespond { call, _ -> val startTime = call.attributes.getOrNull(startTimeKey) ?: return@onCallRespond val duration = System.currentTimeMillis() - startTime call.response.header("X-Response-Time", "${duration}ms") }}
private val startTimeKey = io.ktor.util.AttributeKey<Long>("startTime")
fun Application.configureCustomPlugins() { install(ResponseTimingPlugin)}// Express middleware: add X-Response-Time headerapp.use((req, res, next) => { const start = Date.now(); res.on("finish", () => { const duration = Date.now() - start; res.setHeader("X-Response-Time", `${duration}ms`); }); next();});// Go middlewarefunc ResponseTiming(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) duration := time.Since(start) w.Header().Set("X-Response-Time", fmt.Sprintf("%dms", duration.Milliseconds())) })}// Spring Boot filter — more ceremony@Componentclass ResponseTimingFilter : OncePerRequestFilter() { override fun doFilterInternal( request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain ) { val start = System.currentTimeMillis() filterChain.doFilter(request, response) response.setHeader("X-Response-Time", "${System.currentTimeMillis() - start}ms") }}Ktor’s createApplicationPlugin is the cleanest of the four — just a function with
lifecycle hooks.
Request and Response Handling
Section titled “Request and Response Handling”Receiving the request body
Section titled “Receiving the request body”call.receive<T>() deserializes the JSON body into a data class (using
ContentNegotiation):
post { val request = call.receive<CreateTaskRequest>() // request.title, request.description, etc.}| Framework | Receive body |
|---|---|
| Ktor | call.receive<CreateTaskRequest>() |
| Express | req.body as CreateTaskRequest (with express.json()) |
| Echo | c.Bind(&request) |
| Spring Boot | @RequestBody request: CreateTaskRequest |
Responding
Section titled “Responding”call.respond(taskResponse) // 200 OK + JSONcall.respond(HttpStatusCode.Created, taskResponse) // 201 Created + JSONcall.respondText("Hello, World!") // 200 OK + text/plaincall.respondText("Not Found", status = HttpStatusCode.NotFound)call.respond(HttpStatusCode.NoContent) // 204 No Content// Expressres.json(taskResponse); // ≈ call.respond(taskResponse)res.status(201).json(taskResponse); // ≈ call.respond(HttpStatusCode.Created, taskResponse)res.send("Hello, World!"); // ≈ call.respondText("Hello, World!")res.status(204).send(); // ≈ call.respond(HttpStatusCode.NoContent)// Echoc.JSON(http.StatusOK, taskResponse) // ≈ call.respond(taskResponse)c.JSON(http.StatusCreated, taskResponse) // ≈ call.respond(HttpStatusCode.Created, ...)c.String(http.StatusOK, "Hello, World!") // ≈ call.respondText("Hello, World!")c.NoContent(http.StatusNoContent) // ≈ call.respond(HttpStatusCode.NoContent)Status codes use the HttpStatusCode enum: HttpStatusCode.OK (200),
HttpStatusCode.Created (201), HttpStatusCode.NoContent (204),
HttpStatusCode.BadRequest (400), HttpStatusCode.NotFound (404),
HttpStatusCode.InternalServerError (500), and so on.
Headers
Section titled “Headers”Read with call.request.headers["..."]; set with call.response.header(...):
get("/protected") { val authHeader = call.request.headers["Authorization"] ?: return@get call.respond(HttpStatusCode.Unauthorized, "Missing Authorization header") val requestId = call.request.headers["X-Request-Id"] ?: "unknown"}
post { call.response.header("Location", "/api/tasks/${task.id}") call.response.header("X-Custom-Header", "my-value") call.respond(HttpStatusCode.Created, task.toResponse())}Serialization with kotlinx.serialization
Section titled “Serialization with kotlinx.serialization”Ktor uses kotlinx.serialization by default instead of Jackson. It is a
Kotlin-first, compile-time serialization library — no reflection needed. The
kotlin("plugin.serialization") Gradle plugin generates the serialization code at
compile time; combined with ktor-serialization-kotlinx-json and ContentNegotiation,
JSON is handled automatically.
@Serializable data classes
Section titled “@Serializable data classes”Annotate each DTO with @Serializable:
@Serializabledata class Task( val id: String = UUID.randomUUID().toString(), val title: String, val description: String = "", val status: TaskStatus = TaskStatus.TODO, val priority: Priority = Priority.MEDIUM, val createdAt: String = java.time.Instant.now().toString(), val updatedAt: String = java.time.Instant.now().toString())
@Serializableenum class TaskStatus { TODO, IN_PROGRESS, DONE }
@Serializableenum class Priority { LOW, MEDIUM, HIGH, CRITICAL }
@Serializabledata class CreateTaskRequest( val title: String, val description: String = "", val priority: String = "MEDIUM")
@Serializabledata class UpdateTaskRequest( val title: String? = null, val description: String? = null, val status: String? = null, val priority: String? = null)
fun Task.toResponse() = TaskResponse( id = id, title = title, description = description, status = status.name, priority = priority.name, createdAt = createdAt, updatedAt = updatedAt)kotlinx.serialization vs Jackson vs encoding/json
Section titled “kotlinx.serialization vs Jackson vs encoding/json”| Feature | kotlinx.serialization (Ktor) | Jackson (Spring Boot) | encoding/json (Go) |
|---|---|---|---|
| Annotation | @Serializable | None (auto) or @JsonProperty | json:"field_name" struct tags |
| When codegen runs | Compile time | Runtime reflection | Runtime reflection |
| Null handling | Kotlin nullability | @JsonInclude(NON_NULL) | omitempty tag |
| Default values | Native Kotlin defaults | Needs jackson-module-kotlin | No direct equivalent |
| Custom field names | @SerialName("field_name") | @JsonProperty("field_name") | json:"field_name" |
| Ignore field | @Transient | @JsonIgnore | json:"-" |
| Performance | Faster (no reflection) | Slower (reflection-based) | Fast (Go reflection is fast) |
// TypeScript — no serialization annotations neededinterface CreateTaskRequest { title: string; description?: string; priority?: string;}
const task: CreateTaskRequest = JSON.parse(body);const json = JSON.stringify(task);// Go — struct tags for field namestype CreateTaskRequest struct { Title string `json:"title"` Description string `json:"description,omitempty"` Priority string `json:"priority,omitempty"`}
json.NewDecoder(r.Body).Decode(&req)json.NewEncoder(w).Encode(resp)// Kotlin — @Serializable, native defaults and nullability@Serializabledata class CreateTaskRequest( val title: String, val description: String = "", val priority: String = "MEDIUM")Custom serializers and sealed classes
Section titled “Custom serializers and sealed classes”When you need custom serialization logic (e.g. for java.time.Instant), implement a
KSerializer<Instant>:
object InstantSerializer : KSerializer<Instant> { override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString()) override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())}
@Serializabledata class Task( val id: String, @Serializable(with = InstantSerializer::class) val createdAt: Instant = Instant.now())kotlinx.serialization also handles sealed-class hierarchies natively, emitting a
discriminator field. Spring Boot (Jackson) requires @JsonTypeInfo and @JsonSubTypes
annotations:
@Serializablesealed class NotificationEvent { @Serializable data class TaskCreated(val taskId: String, val title: String) : NotificationEvent()
@Serializable data class TaskCompleted(val taskId: String, val completedBy: String) : NotificationEvent()}// Serializes as: {"type":"com.example.TaskCreated","taskId":"abc","title":"My Task"}Dependency Injection with Koin
Section titled “Dependency Injection with Koin”Ktor has no built-in DI. The common choices are Koin (lightweight, DSL-based, Kotlin-first — most popular with Ktor), Kodein, or just manual wiring through constructors. We use Koin here because it mirrors the framework’s simplicity.
Koin setup
Section titled “Koin setup”Define bindings in a module { } block:
val appModule = module { // single — one instance for the entire application single<TaskRepository> { InMemoryTaskRepository() }}Install Koin alongside the other plugins, then start the routes:
fun main() { embeddedServer(Netty, port = 8080) { module() }.start(wait = true)}
fun Application.module() { install(Koin) { slf4jLogger() modules(appModule) } configureSerialization() configureErrorHandling() configureCallLogging() routing { taskRoutes() }}Injecting into route handlers
Section titled “Injecting into route handlers”Inside a route file, use Koin’s by inject<T>() delegate:
fun Route.taskRoutes() { val repository by inject<TaskRepository>()
route("/api/tasks") { get { call.respond(repository.findAll().map { it.toResponse() }) } }}single { } gives a singleton; factory { } gives a new instance each time; get()
resolves another binding. You can split definitions across multiple modules and pass
them all to modules(repositoryModule, serviceModule).
Koin vs Spring DI vs Go vs tsyringe
Section titled “Koin vs Spring DI vs Go vs tsyringe”// tsyringe@injectable()class TaskService { constructor(@inject("TaskRepository") private repo: TaskRepository) {}}
const service = container.resolve(TaskService);// Manual wiringrepo := NewInMemoryTaskRepository()service := NewTaskService(repo)handler := NewTaskHandler(service)
r.Get("/api/tasks", handler.ListTasks)// Koin (Ktor) — DSL-basedval appModule = module { single<TaskRepository> { InMemoryTaskRepository() } single { TaskService(get()) }}val repository by inject<TaskRepository>()
// Spring Boot — annotation-based, auto-wired@Repositoryclass InMemoryTaskRepository : TaskRepository { /* ... */ }@Serviceclass TaskService(private val repository: TaskRepository)| Feature | Spring DI | Koin | Go (manual) | tsyringe |
|---|---|---|---|---|
| Annotation-based | Yes (@Service, @Repository) | No | No | Yes (@injectable) |
| DSL-based | No | Yes (module { }) | No | No |
| Compile-time safety | No (runtime errors) | No (runtime errors) | Yes (compiler checks) | No (runtime errors) |
| Reflection | Heavy | Minimal | None | Heavy |
| Auto-discovery | Yes (classpath scanning) | No (explicit) | No (explicit) | No (explicit) |
Request Validation
Section titled “Request Validation”Ktor provides a RequestValidation plugin. Unlike Spring Boot’s annotation-based
Jakarta Bean Validation, Ktor validation is code-based — you write the rules as a
lambda, closest in spirit to Zod in TypeScript.
Setting up RequestValidation
Section titled “Setting up RequestValidation”fun Application.configureValidation() { install(RequestValidation) { validate<CreateTaskRequest> { request -> val errors = mutableListOf<String>()
if (request.title.isBlank()) errors.add("Title is required and cannot be blank") if (request.title.length > 200) errors.add("Title must be at most 200 characters") if (request.description.length > 2000) errors.add("Description must be at most 2000 characters") if (request.priority !in listOf("LOW", "MEDIUM", "HIGH", "CRITICAL")) { errors.add("Priority must be one of: LOW, MEDIUM, HIGH, CRITICAL") }
if (errors.isEmpty()) ValidationResult.Valid else ValidationResult.Invalid(errors) } }}A failed validation throws RequestValidationException, which you handle in
StatusPages:
install(StatusPages) { exception<RequestValidationException> { call, cause -> call.respond( HttpStatusCode.BadRequest, ErrorResponse(400, "Validation Failed", cause.reasons.joinToString("; ")) ) }}Validation across frameworks
Section titled “Validation across frameworks”// Zod — validation as code, like Ktorconst CreateTaskSchema = z.object({ title: z.string().min(1, "Title is required").max(200), description: z.string().max(2000).optional(), priority: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]).optional()});
CreateTaskSchema.parse(req.body);// Struct tagstype CreateTaskRequest struct { Title string `json:"title" validate:"required,min=1,max=200"`}
validate.Struct(request)// Ktor — code-basedvalidate<CreateTaskRequest> { request -> if (request.title.isBlank()) ValidationResult.Invalid("Title is required") else ValidationResult.Valid}
// Spring Boot — annotation-baseddata class CreateTaskRequest( @field:NotBlank(message = "Title is required") @field:Size(min = 1, max = 200) val title: String) // controller: @Valid @RequestBody request: CreateTaskRequestKtor’s approach is flexible for complex rules but more verbose for simple field constraints than annotations.
Authentication (JWT)
Section titled “Authentication (JWT)”Ktor has a built-in authentication plugin supporting Basic, Digest, Bearer (JWT),
OAuth, Session, and custom schemes. You configure an auth scheme, then wrap routes in
authenticate("scheme-name") { }.
JWT setup
Section titled “JWT setup”fun Application.configureAuthentication() { val jwtSecret = environment.config.property("jwt.secret").getString() val jwtIssuer = environment.config.property("jwt.issuer").getString() val jwtAudience = environment.config.property("jwt.audience").getString()
install(Authentication) { jwt("auth-jwt") { realm = "Task API" verifier( JWT.require(Algorithm.HMAC256(jwtSecret)) .withAudience(jwtAudience) .withIssuer(jwtIssuer) .build() ) validate { credential -> if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null } challenge { _, _ -> call.respond( HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized", "Invalid or missing token") ) } } }}Protecting routes
Section titled “Protecting routes”Wrap protected routes in authenticate("auth-jwt") { }. Public routes stay outside it.
Inside, call.principal<JWTPrincipal>() gives you the verified token:
fun Route.taskRoutes() { route("/api/tasks") { get { /* public — anyone can list tasks */ }
authenticate("auth-jwt") { post { val principal = call.principal<JWTPrincipal>()!! val userId = principal.payload.getClaim("userId").asString() // create task for this user } delete("/{id}") { val principal = call.principal<JWTPrincipal>()!! // delete task, check ownership } } }}Generating tokens
Section titled “Generating tokens”post("/api/auth/login") { val request = call.receive<LoginRequest>() // Validate credentials (simplified for example) if (request.username == "admin" && request.password == "secret") { val token = JWT.create() .withAudience(jwtAudience) .withIssuer(jwtIssuer) .withClaim("userId", "user-1") .withClaim("username", request.username) .withExpiresAt(java.util.Date(System.currentTimeMillis() + 3600_000)) .sign(Algorithm.HMAC256(jwtSecret)) call.respond(mapOf("token" to token)) } else { call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized", "Invalid credentials")) }}How the same JWT verification looks elsewhere:
// Express (jsonwebtoken)const authMiddleware = (req, res, next) => { const token = req.headers.authorization?.split(" ")[1]; if (!token) return res.status(401).json({ error: "Unauthorized" }); try { req.user = jwt.verify(token, JWT_SECRET); next(); } catch { res.status(401).json({ error: "Invalid token" }); }};
router.post("/tasks", authMiddleware, createTask);// Echo (golang-jwt)config := echojwt.Config{ SigningKey: []byte(jwtSecret) }e.Use(echojwt.WithConfig(config))
token := c.Get("user").(*jwt.Token)claims := token.Claims.(jwt.MapClaims)userId := claims["userId"].(string)// Spring Boot (Spring Security)@Beanfun securityFilterChain(http: HttpSecurity): SecurityFilterChain { return http .authorizeHttpRequests { it.requestMatchers(HttpMethod.GET, "/api/tasks/**").permitAll() it.anyRequest().authenticated() } .oauth2ResourceServer { it.jwt {} } .build()}Ktor’s auth is more explicit than Spring Security (which has a steep learning curve) and comparable to Express or Go in directness.
Testing
Section titled “Testing”Ktor provides a first-class testing DSL that starts your application in-process without
binding to a real port. It is faster than Spring Boot’s @SpringBootTest and similar
to Go’s httptest.
The testApplication DSL
Section titled “The testApplication DSL”class TaskRoutesTest {
@Test fun `GET tasks should return empty list initially`() = testApplication { application { module() }
val response = client.get("/api/tasks") assertEquals(HttpStatusCode.OK, response.status) assertEquals("[]", response.bodyAsText()) }}That is the entire setup. No @SpringBootTest, no @AutoConfigureMockMvc, no
@Autowired — just testApplication { } and go.
Testing CRUD operations
Section titled “Testing CRUD operations”@Testfun `POST should create a task`() = testApplication { application { module() }
val response = client.post("/api/tasks") { contentType(ContentType.Application.Json) setBody("""{"title": "Test task", "description": "Testing", "priority": "HIGH"}""") }
assertEquals(HttpStatusCode.Created, response.status) val body = response.bodyAsText() assertTrue(body.contains("Test task")) assertTrue(body.contains("HIGH"))}
@Testfun `GET should return 404 for nonexistent task`() = testApplication { application { module() } val response = client.get("/api/tasks/nonexistent-id") assertEquals(HttpStatusCode.NotFound, response.status)}
@Testfun `POST should return 400 for blank title`() = testApplication { application { module() } val response = client.post("/api/tasks") { contentType(ContentType.Application.Json) setBody("""{"title": " "}""") } assertEquals(HttpStatusCode.BadRequest, response.status)}Typed clients and mock dependencies
Section titled “Typed clients and mock dependencies”For cleaner tests, configure the test client with content negotiation so you can send and receive typed bodies:
@Testfun `POST should create and return task`() = testApplication { application { module() }
val jsonClient = createClient { install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json() } }
val response = jsonClient.post("/api/tasks") { contentType(ContentType.Application.Json) setBody(CreateTaskRequest(title = "Typed test", priority = "HIGH")) }
assertEquals(HttpStatusCode.Created, response.status) val task = response.body<TaskResponse>() assertEquals("Typed test", task.title)}You can also override Koin modules to inject fakes:
@Testfun `should use mock repository`() = testApplication { application { install(Koin) { modules(module { single<TaskRepository> { FakeTaskRepository() } }) } configureSerialization() configureErrorHandling() routing { taskRoutes() } }
val response = client.get("/api/tasks") assertEquals(HttpStatusCode.OK, response.status)}Testing across frameworks
Section titled “Testing across frameworks”// supertestit("POST creates task", async () => { const res = await request(app) .post("/api/tasks") .send({ title: "Test" }) .expect(201);});// httptestfunc TestCreateTask(t *testing.T) { body := `{"title": "Test"}` req := httptest.NewRequest("POST", "/api/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) assert.Equal(t, 201, rr.Code)}// Ktor testApplication@Testfun `POST creates task`() = testApplication { application { module() } val response = client.post("/api/tasks") { contentType(ContentType.Application.Json) setBody("""{"title": "Test"}""") } assertEquals(HttpStatusCode.Created, response.status)}
// Spring Boot MockMvc — annotations + context startup@SpringBootTest@AutoConfigureMockMvcclass TaskControllerTest { @Autowired lateinit var mockMvc: MockMvc @Test fun `POST creates task`() { mockMvc.post("/api/tasks") { contentType = MediaType.APPLICATION_JSON content = """{"title": "Test"}""" }.andExpect { status { isCreated() } } }}| Framework | Test setup overhead | Starts real server? | Speed |
|---|---|---|---|
Ktor testApplication | None (one function call) | No (in-process) | Very fast |
Spring @SpringBootTest + MockMvc | Annotations, context startup | No (mock servlet) | Slow (~2-5s startup) |
| supertest (Express) | Import app | No (in-process) | Fast |
| Go httptest | Create request + recorder | No (in-process) | Very fast |
Ktor’s testing story is one of its strongest advantages: tests start in milliseconds with zero boilerplate.
Ktor vs Spring Boot: An Honest Comparison
Section titled “Ktor vs Spring Boot: An Honest Comparison”You have now seen the same task management API in both Spring Boot (Module 08) and Ktor. Here is a direct comparison.
Performance
Section titled “Performance”| Metric | Spring Boot | Ktor |
|---|---|---|
| Startup time | 1-3 seconds | 100-300ms |
| Memory (idle) | 100-300MB | 30-80MB |
| Memory (under load) | 200-500MB | 50-150MB |
| Requests/sec (simple JSON) | ~50-80K | ~60-100K |
| Requests/sec (with DB) | ~10-30K | ~10-30K |
Ktor has a clear edge in startup time and memory. For raw throughput, both are comparable once your bottleneck is the database (which it usually is). The startup difference matters for serverless (Lambda, Cloud Run) and container scaling.
Ecosystem maturity
Section titled “Ecosystem maturity”| Area | Spring Boot | Ktor |
|---|---|---|
| ORM / Database | Spring Data JPA, JDBC | Exposed, Ktorm, raw JDBC |
| Security | Spring Security (comprehensive) | Basic auth plugin + manual |
| Caching | Spring Cache abstraction | Manual (Redis client, etc.) |
| Messaging | Spring Kafka, Spring AMQP | Raw Kafka/AMQP clients |
| Monitoring | Actuator + Micrometer (built-in) | Manual (Micrometer plugin available) |
| API docs | SpringDoc OpenAPI (mature) | Ktor OpenAPI plugin (newer) |
| Job scheduling | @Scheduled (built-in) | Coroutine-based (manual) |
| Rate limiting | Spring Cloud Gateway | Manual or third-party |
Spring Boot’s ecosystem is enormous: for every infrastructure concern there is usually a Spring starter. Ktor requires you to integrate libraries manually — more control, less convenience.
Learning curve
Section titled “Learning curve”| Aspect | Spring Boot | Ktor |
|---|---|---|
| Initial setup | Moderate (Initializr helps) | Easy (few dependencies) |
| First endpoint | Moderate (annotations, injection) | Easy (DSL, direct) |
| Error handling | Learn @ControllerAdvice | Learn StatusPages (simpler) |
| DI | Understand Spring IoC, annotations | Koin is straightforward |
| Testing | Learn MockMvc, test slices, context | testApplication is trivial |
| Advanced (security, transactions, caching) | Steep (Spring-specific concepts) | Moderate (assemble pieces yourself) |
If you are coming from Express.js or Go, Ktor will feel more natural initially. Spring Boot requires learning more framework-specific concepts, but those pay off for complex applications.
Job market (as of 2025-2026)
Section titled “Job market (as of 2025-2026)”| Spring Boot | Ktor | |
|---|---|---|
| Job postings | Very high | Low-moderate |
| Enterprise adoption | Dominant in JVM enterprise | Growing, niche |
| Typical users | Banks, insurance, large enterprises | Startups, mobile backends, microservices |
| Consultant demand | High | Low |
The same endpoint, four ways
Section titled “The same endpoint, four ways”The “create task” endpoint across all four frameworks:
router.post("/", async (req, res) => { const request = req.body as CreateTaskRequest; const task = taskService.create(request); res.status(201).location(`/api/tasks/${task.id}`).json(task);});// Echoe.POST("/api/tasks", func(c echo.Context) error { var req CreateTaskRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } task := taskService.Create(req) return c.JSON(http.StatusCreated, task)})// Ktor — most conciseroute("/api/tasks") { post { val request = call.receive<CreateTaskRequest>() val task = Task(title = request.title, priority = Priority.valueOf(request.priority)) call.respond(HttpStatusCode.Created, repository.save(task).toResponse()) }}
// Spring Boot — most structured@RestController@RequestMapping("/api/tasks")class TaskController(private val taskService: TaskService) { @PostMapping fun createTask(@Valid @RequestBody request: CreateTaskRequest): ResponseEntity<TaskResponse> { val task = taskService.create(request) return ResponseEntity.created(URI.create("/api/tasks/${task.id}")).body(task) }}Summary
Section titled “Summary”| Concept | What you learned |
|---|---|
| Project setup | build.gradle.kts with the Ktor plugin; embeddedServer vs EngineMain |
| Routing | DSL-based routes: get, post, put, delete, route nesting |
| Plugins | install(Plugin) pattern: ContentNegotiation, StatusPages, CORS, CallLogging |
| Request/Response | call.receive<T>(), call.respond(), call.parameters, status codes |
| Serialization | kotlinx.serialization: @Serializable, compile-time, no reflection |
| DI with Koin | module { single { } }, by inject<T>() in routes |
| Validation | RequestValidation plugin, code-based validation rules |
| Authentication | JWT plugin with authenticate("name") { } route wrapping |
| Testing | testApplication { } DSL — fast, no server startup, minimal boilerplate |
| Ktor vs Spring | Lighter, faster startup, less ecosystem; choose based on project needs |
Practice
Section titled “Practice”Put Ktor to work by building the same projects two ways — first a custom plugin exercise to master the plugin system, then the full CRUD API to compare directly against the Spring Boot version from Module 08.