Skip to content

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.

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.

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
  • Kotlin-native: No Java legacy, no annotation magic, no reflection at runtime.
  • Coroutine-first: Every handler is a suspend function — 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.

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
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
}
}

The project name lives in one line:

settings.gradle.kts
rootProject.name = "task-api-ktor"

Ktor offers two ways to start a server. embeddedServer is programmatic — best for learning and tests. EngineMain is configuration-driven — best for production.

src/main/kotlin/com/example/taskapi/Application.kt
// Option 1: embeddedServer (programmatic)
fun main() {
embeddedServer(Netty, port = 8080) {
configureRouting()
configureSerialization()
configureErrorHandling()
}.start(wait = true)
}
src/main/kotlin/com/example/taskapi/Application.kt
// 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:

src/main/resources/application.conf
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:

// Express
const app = express();
app.use(express.json()); // ≈ configureSerialization()
app.use(errorHandler); // ≈ configureErrorHandling()
app.use("/api", router); // ≈ configureRouting()
app.listen(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
  • 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
Terminal window
./gradlew run # run directly
./gradlew buildFatJar # build a fat JAR
java -jar build/libs/task-api-ktor-all.jar
./gradlew buildImage # build a Docker image (Ktor plugin provides this)

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.

src/main/kotlin/com/example/taskapi/routes/HelloRoutes.kt
fun Route.helloRoutes() {
get("/hello") {
call.respondText("Hello, World!")
}
get("/hello/json") {
call.respond(mapOf("message" to "Hello, World!"))
}
}

Wire it up with routing { }:

src/main/kotlin/com/example/taskapi/Application.kt
fun Application.configureRouting() {
routing {
helloRoutes()
}
}

The same two endpoints across frameworks:

// Express.js — almost identical structure
const 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);

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);

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 parameters
get("/users/{userId}/tasks/{taskId}") {
val userId = call.parameters["userId"]!!
val taskId = call.parameters["taskId"]!!
}
// Express
router.get("/:id", (req, res) => {
const id = req.params.id; // ≈ call.parameters["id"]
});

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))
}
// Express
router.get("/search", (req, res) => {
const q = req.query.q; // ≈ queryParameters["q"]
const page = parseInt(req.query.page) || 0; // ≈ ?.toIntOrNull() ?: 0
});

This mirrors the Spring Boot TaskController from Module 08. The repository is injected via Koin’s by inject<TaskRepository>() (covered below):

src/main/kotlin/com/example/taskapi/routes/TaskRoutes.kt
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.

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:

Ktor request pipeline
Rendering diagram…
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)

ContentNegotiation with kotlinx.serialization

Section titled “ContentNegotiation with kotlinx.serialization”

This plugin handles automatic JSON serialization and deserialization:

src/main/kotlin/com/example/taskapi/plugins/Serialization.kt
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 is Ktor’s equivalent of Spring Boot’s @ControllerAdvice / @ExceptionHandler — centralized error handling via a lambda DSL:

src/main/kotlin/com/example/taskapi/plugins/ErrorHandling.kt
@Serializable
data 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 middleware
app.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" });
});

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()}"
}
}
}

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 header
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
res.setHeader("X-Response-Time", `${duration}ms`);
});
next();
});

Ktor’s createApplicationPlugin is the cleanest of the four — just a function with lifecycle hooks.

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
call.respond(taskResponse) // 200 OK + JSON
call.respond(HttpStatusCode.Created, taskResponse) // 201 Created + JSON
call.respondText("Hello, World!") // 200 OK + text/plain
call.respondText("Not Found", status = HttpStatusCode.NotFound)
call.respond(HttpStatusCode.NoContent) // 204 No Content
// Express
res.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)

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.

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())
}

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.

Annotate each DTO with @Serializable:

src/main/kotlin/com/example/taskapi/models/Task.kt
@Serializable
data 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()
)
@Serializable
enum class TaskStatus { TODO, IN_PROGRESS, DONE }
@Serializable
enum class Priority { LOW, MEDIUM, HIGH, CRITICAL }
@Serializable
data class CreateTaskRequest(
val title: String,
val description: String = "",
val priority: String = "MEDIUM"
)
@Serializable
data 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 needed
interface CreateTaskRequest {
title: string;
description?: string;
priority?: string;
}
const task: CreateTaskRequest = JSON.parse(body);
const json = JSON.stringify(task);

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())
}
@Serializable
data 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:

@Serializable
sealed 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"}

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.

Define bindings in a module { } block:

src/main/kotlin/com/example/taskapi/di/AppModule.kt
val appModule = module {
// single — one instance for the entire application
single<TaskRepository> { InMemoryTaskRepository() }
}

Install Koin alongside the other plugins, then start the routes:

src/main/kotlin/com/example/taskapi/Application.kt
fun main() {
embeddedServer(Netty, port = 8080) { module() }.start(wait = true)
}
fun Application.module() {
install(Koin) {
slf4jLogger()
modules(appModule)
}
configureSerialization()
configureErrorHandling()
configureCallLogging()
routing {
taskRoutes()
}
}

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).

// tsyringe
@injectable()
class TaskService {
constructor(@inject("TaskRepository") private repo: TaskRepository) {}
}
const service = container.resolve(TaskService);
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)

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.

src/main/kotlin/com/example/taskapi/plugins/Validation.kt
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("; "))
)
}
}
// Zod — validation as code, like Ktor
const 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);

Ktor’s approach is flexible for complex rules but more verbose for simple field constraints than annotations.

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") { }.

src/main/kotlin/com/example/taskapi/plugins/Auth.kt
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")
)
}
}
}
}

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
}
}
}
}
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);

Ktor’s auth is more explicit than Spring Security (which has a steep learning curve) and comparable to Express or Go in directness.

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.

src/test/kotlin/com/example/taskapi/TaskRoutesTest.kt
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.

@Test
fun `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"))
}
@Test
fun `GET should return 404 for nonexistent task`() = testApplication {
application { module() }
val response = client.get("/api/tasks/nonexistent-id")
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Test
fun `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)
}

For cleaner tests, configure the test client with content negotiation so you can send and receive typed bodies:

@Test
fun `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:

@Test
fun `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)
}
// supertest
it("POST creates task", async () => {
const res = await request(app)
.post("/api/tasks")
.send({ title: "Test" })
.expect(201);
});
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.

You have now seen the same task management API in both Spring Boot (Module 08) and Ktor. Here is a direct comparison.

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.

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.

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.

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 “create task” endpoint across all four frameworks:

Express.js
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);
});
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

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.