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.

ConceptExpress.js (TS)Go (Echo/Chi)Spring Boot (Kotlin)Ktor (Kotlin)
PhilosophyMinimal, middleware-basedMinimal, stdlib + routerOpinionated, batteries-includedMinimal, plugin-based
ServerNode.js runtimeGo runtimeEmbedded Tomcat/NettyEmbedded Netty/CIO/Jetty
Routingapp.get("/path", handler)e.GET("/path", handler)@GetMapping("/path")get("/path") { } DSL
Middlewareapp.use(fn)e.Use(fn)Filters / Interceptorsinstall(Plugin)
DItsyringe / manualwire / manualBuilt-in IoC containerKoin / Kodein / manual
JSONBuilt-in JSON.parseencoding/jsonJackson (auto-configured)kotlinx.serialization
Async modelEvent loop (single thread)GoroutinesVirtual threads / CoroutinesCoroutines (native)
Configdotenv / configenvconfig / Viperapplication.ymlapplication.conf (HOCON)
TestingJest + supertesttesting + httptestMockMvc / WebTestClienttestApplication { } 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 BootKtorWhy
kotlin("plugin.spring")kotlin("plugin.serialization")Spring needs open classes; Ktor needs serialization codegen
org.springframework.boot pluginio.ktor.pluginFramework-specific Gradle plugins
spring-boot-starter-web (one dep)Multiple ktor-server-* depsKtor: pick individual features
jackson-module-kotlinktor-serialization-kotlinx-jsonDifferent JSON libraries
kotlin-reflect (required)Not neededKtor 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 CaseembeddedServerEngineMain
Quick prototypesYes
TestsYes
Full control over startupYes
External configuration (port, modules)Yes
Production deploymentsYes
Multiple environmentsYes
  • 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 BootKtorNotes
controller/TaskController.ktroutes/TaskRoutes.ktAnnotations vs. DSL functions
service/TaskService.ktservice/TaskService.ktSame concept, no @Service
model/Task.ktmodels/Task.kt@Serializable vs. Jackson
config/AppConfig.ktplugins/Serialization.kt@Configuration vs. install()
exception/GlobalExceptionHandler.ktplugins/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.
}
FrameworkReceive body
Ktorcall.receive<CreateTaskRequest>()
Expressreq.body as CreateTaskRequest (with express.json())
Echoc.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”
Featurekotlinx.serialization (Ktor)Jackson (Spring Boot)encoding/json (Go)
Annotation@SerializableNone (auto) or @JsonPropertyjson:"field_name" struct tags
When codegen runsCompile timeRuntime reflectionRuntime reflection
Null handlingKotlin nullability@JsonInclude(NON_NULL)omitempty tag
Default valuesNative Kotlin defaultsNeeds jackson-module-kotlinNo direct equivalent
Custom field names@SerialName("field_name")@JsonProperty("field_name")json:"field_name"
Ignore field@Transient@JsonIgnorejson:"-"
PerformanceFaster (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);
FeatureSpring DIKoinGo (manual)tsyringe
Annotation-basedYes (@Service, @Repository)NoNoYes (@injectable)
DSL-basedNoYes (module { })NoNo
Compile-time safetyNo (runtime errors)No (runtime errors)Yes (compiler checks)No (runtime errors)
ReflectionHeavyMinimalNoneHeavy
Auto-discoveryYes (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);
});
FrameworkTest setup overheadStarts real server?Speed
Ktor testApplicationNone (one function call)No (in-process)Very fast
Spring @SpringBootTest + MockMvcAnnotations, context startupNo (mock servlet)Slow (~2-5s startup)
supertest (Express)Import appNo (in-process)Fast
Go httptestCreate request + recorderNo (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.

MetricSpring BootKtor
Startup time1-3 seconds100-300ms
Memory (idle)100-300MB30-80MB
Memory (under load)200-500MB50-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.

AreaSpring BootKtor
ORM / DatabaseSpring Data JPA, JDBCExposed, Ktorm, raw JDBC
SecuritySpring Security (comprehensive)Basic auth plugin + manual
CachingSpring Cache abstractionManual (Redis client, etc.)
MessagingSpring Kafka, Spring AMQPRaw Kafka/AMQP clients
MonitoringActuator + Micrometer (built-in)Manual (Micrometer plugin available)
API docsSpringDoc OpenAPI (mature)Ktor OpenAPI plugin (newer)
Job scheduling@Scheduled (built-in)Coroutine-based (manual)
Rate limitingSpring Cloud GatewayManual 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.

AspectSpring BootKtor
Initial setupModerate (Initializr helps)Easy (few dependencies)
First endpointModerate (annotations, injection)Easy (DSL, direct)
Error handlingLearn @ControllerAdviceLearn StatusPages (simpler)
DIUnderstand Spring IoC, annotationsKoin is straightforward
TestingLearn MockMvc, test slices, contexttestApplication 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 BootKtor
Job postingsVery highLow-moderate
Enterprise adoptionDominant in JVM enterpriseGrowing, niche
Typical usersBanks, insurance, large enterprisesStartups, mobile backends, microservices
Consultant demandHighLow

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);
});
ConceptWhat you learned
Project setupbuild.gradle.kts with the Ktor plugin; embeddedServer vs EngineMain
RoutingDSL-based routes: get, post, put, delete, route nesting
Pluginsinstall(Plugin) pattern: ContentNegotiation, StatusPages, CORS, CallLogging
Request/Responsecall.receive<T>(), call.respond(), call.parameters, status codes
Serializationkotlinx.serialization: @Serializable, compile-time, no reflection
DI with Koinmodule { single { } }, by inject<T>() in routes
ValidationRequestValidation plugin, code-based validation rules
AuthenticationJWT plugin with authenticate("name") { } route wrapping
TestingtestApplication { } DSL — fast, no server startup, minimal boilerplate
Ktor vs SpringLighter, 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.