Skip to content

Task Management CRUD API (Ktor)

Build a complete REST API for task management using Ktor 3.x: full CRUD plus search and status-filter endpoints, request validation, centralized error handling, dependency injection with Koin, and integration tests.

This is the Ktor counterpart to the Spring Boot CRUD exercise in Module 08. It builds the same API — same endpoints, same DTOs, same TaskRepository interface — so you can read the two side by side and see exactly where the frameworks diverge. The headline difference: Ktor is explicit and code-first (you install plugins and wire DI by hand) where Spring Boot is annotation-driven and convention-first (it auto-scans and auto-configures).

  • Full CRUD operations (Create, Read, Update, Delete) under /api/tasks
  • Search (/api/tasks/search?q=term) and status filter (/api/tasks/status/{status})
  • Request validation via the RequestValidation plugin (code-based, not annotations)
  • Centralized error handling via the StatusPages plugin
  • Dependency injection with Koin (module { single { } })
  • JSON via kotlinx.serialization (@Serializable data classes)
  • In-memory storage behind the same repository interface as the Spring Boot exercise
  • Integration tests with the testApplication { } DSL
MethodPathDescription
GET/api/tasksList all tasks
GET/api/tasks/{id}Get a task by ID
POST/api/tasksCreate a new task
PUT/api/tasks/{id}Update an existing task
DELETE/api/tasks/{id}Delete a task
GET/api/tasks/status/{status}Filter tasks by status
GET/api/tasks/search?q=termSearch tasks by title/description

This table is the heart of the comparison — keep it handy as you read the code.

ConceptSpring Boot (Module 08)Ktor (this exercise)
Entry point@SpringBootApplication + runApplication<>()embeddedServer(Netty) { module() }
Routing@RestController + @GetMappingroute("/api/tasks") { get { } }
DI@Service, @Repository (auto-scanned)Koin module { single { } }
JSONJackson (auto-configured)kotlinx.serialization (@Serializable)
Validation@field:NotBlank, @ValidRequestValidation plugin (code-based)
Error handling@RestControllerAdvice + @ExceptionHandlerStatusPages plugin
Testing@SpringBootTest + MockMvctestApplication { } DSL
Startup time~1-3 seconds~100-300ms
DependenciesOne starter pulls everythingIndividual ktor-server-* deps

The project splits the app into focused files — models, repository, routes, and a small plugins/ package where each Ktor plugin gets its own configuration function. Application.kt wires them together.

  • Directorytask-api-ktor/
    • build.gradle.kts deps + the Ktor Gradle plugin
    • settings.gradle.kts project name
    • Directorysrc/main/kotlin/com/example/taskapi/
      • Application.kt entry point + module wiring
      • Directorydi/
        • AppModule.kt Koin DI module
      • Directorymodels/
        • Task.kt domain model, DTOs, serialization
      • Directoryplugins/
        • CallLogging.kt request logging
        • ErrorHandling.kt StatusPages + custom exceptions
        • Serialization.kt ContentNegotiation + kotlinx.serialization
        • Validation.kt RequestValidation rules
      • Directoryrepository/
        • TaskRepository.kt data-access interface + in-memory impl
      • Directoryroutes/
        • TaskRoutes.kt REST route definitions
    • Directorysrc/test/kotlin/com/example/taskapi/
      • TaskRoutesTest.kt testApplication integration tests

We’ll walk the highlights in the order the app comes together: build config → module wiring → routing → service plugins → repository → tests.

Unlike Spring Boot’s single “starter” dependency, Ktor makes you pull in each feature as its own artifact — that’s the explicit-over-magic tradeoff. The io.ktor.plugin Gradle plugin adds conveniences like the buildFatJar task.

build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.serialization") version "2.1.0"
id("io.ktor.plugin") version "3.1.0"
}
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
implementation("io.ktor:ktor-server-core:$ktorVersion")
implementation("io.ktor:ktor-server-netty:$ktorVersion")
implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-server-status-pages:$ktorVersion")
implementation("io.ktor:ktor-server-cors:$ktorVersion")
implementation("io.ktor:ktor-server-call-logging:$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")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test> {
useJUnitPlatform()
}

Application.kt — entry point and module wiring

Section titled “Application.kt — entry point and module wiring”

main boots Netty on port 8080 and calls module(). Application.module() is an extension function on Application that installs everything: Koin first, then the plugins, then the routes. Because the wiring lives in module() rather than main, the tests can call the exact same setup via testApplication { application { module() } }.

The comments below carry the cross-framework mapping — note how install(...) is the explicit hand-written equivalent of Spring’s @ComponentScan / @RestControllerAdvice magic.

src/main/kotlin/com/example/taskapi/Application.kt
package com.example.taskapi
import com.example.taskapi.di.appModule
import com.example.taskapi.plugins.configureCallLogging
import com.example.taskapi.plugins.configureErrorHandling
import com.example.taskapi.plugins.configureSerialization
import com.example.taskapi.plugins.configureValidation
import com.example.taskapi.routes.taskRoutes
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
fun main() {
embeddedServer(Netty, port = 8080) {
module()
}.start(wait = true)
}
fun Application.module() {
// 1. Install Koin for dependency injection
// Spring Boot equivalent: @ComponentScan + @Service/@Repository annotations
install(Koin) {
slf4jLogger()
modules(appModule)
}
// 2. Install plugins (middleware)
configureSerialization() // ≈ Jackson auto-configuration
configureValidation() // ≈ Jakarta Bean Validation
configureErrorHandling() // ≈ @RestControllerAdvice
configureCallLogging() // ≈ Spring request logging
// 3. Install routes
// Spring Boot equivalent: @RestController classes with @RequestMapping
routing {
taskRoutes()
}
}

For a TS/Go dev, that module() body reads almost exactly like an Express or Echo setup:

const app = express();
app.use(express.json()); // ≈ ContentNegotiation
app.use(morgan("dev")); // ≈ CallLogging
app.use("/api/tasks", taskRouter);
app.listen(8080);

fun Route.taskRoutes() is the Ktor equivalent of Spring’s TaskController. The repository is pulled from Koin with val repository by inject<TaskRepository>() — the property-delegate version of @Autowired. Inside route("/api/tasks") { }, each verb (get, post, put, delete) registers a handler.

src/main/kotlin/com/example/taskapi/routes/TaskRoutes.kt
package com.example.taskapi.routes
import com.example.taskapi.models.*
import com.example.taskapi.plugins.InvalidTaskStatusException
import com.example.taskapi.plugins.TaskNotFoundException
import com.example.taskapi.repository.TaskRepository
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.koin.ktor.ext.inject
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 -- search tasks
// NOTE: This route must be defined BEFORE "/{id}" to avoid "search" being
// interpreted as an ID. Same issue exists in Express and Go.
get("/search") {
val query = call.request.queryParameters["q"]
?: return@get call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to "Missing required query parameter 'q'")
)
val results = repository.search(query).map { it.toResponse() }
call.respond(results)
}
// GET /api/tasks/status/{status} -- filter by status
get("/status/{status}") {
val statusParam = call.parameters["status"]!!
val status = try {
TaskStatus.valueOf(statusParam.uppercase())
} catch (e: IllegalArgumentException) {
throw InvalidTaskStatusException(statusParam)
}
val tasks = repository.findByStatus(status).map { it.toResponse() }
call.respond(tasks)
}
// GET /api/tasks/{id} -- get a single task
get("/{id}") {
val id = call.parameters["id"]!!
val task = repository.findById(id)
?: throw TaskNotFoundException(id)
call.respond(task.toResponse())
}
// POST /api/tasks -- create a new task
post {
val request = call.receive<CreateTaskRequest>()
val task = Task(
title = request.title,
description = request.description,
priority = Priority.valueOf(request.priority.uppercase())
)
val saved = repository.save(task)
call.response.header("Location", "/api/tasks/${saved.id}")
call.respond(HttpStatusCode.Created, saved.toResponse())
}
// PUT /api/tasks/{id} -- update an existing task
put("/{id}") {
val id = call.parameters["id"]!!
val existing = repository.findById(id)
?: throw TaskNotFoundException(id)
val request = call.receive<UpdateTaskRequest>()
val updatedStatus = request.status?.let {
try {
TaskStatus.valueOf(it.uppercase())
} catch (e: IllegalArgumentException) {
throw InvalidTaskStatusException(it)
}
} ?: existing.status
val updatedPriority = request.priority?.let {
Priority.valueOf(it.uppercase())
} ?: existing.priority
val updated = existing.copy(
title = request.title ?: existing.title,
description = request.description ?: existing.description,
status = updatedStatus,
priority = updatedPriority,
updatedAt = java.time.Instant.now().toString()
)
val saved = repository.save(updated)
call.respond(saved.toResponse())
}
// DELETE /api/tasks/{id} -- delete a task
delete("/{id}") {
val id = call.parameters["id"]!!
val deleted = repository.deleteById(id)
if (!deleted) {
throw TaskNotFoundException(id)
}
call.respond(HttpStatusCode.NoContent)
}
}
}

A few things worth pointing out for TS/Go developers:

  • call.receive<CreateTaskRequest>() deserializes the JSON body into a typed DTO — like req.body after express.json() but type-checked, or Go’s c.Bind(&req).
  • The handlers don’t try/catch “not found” everywhere. They just throw TaskNotFoundException, and the StatusPages plugin turns it into a 404. That’s the Ktor equivalent of a Spring @ExceptionHandler.
  • Route ordering matters: /search and /status/{status} are declared before /{id}, otherwise Ktor would match search as an {id}. The same gotcha exists in Express (/:id) and chi (/{id}).
  • The ?: (Elvis) operator does double duty here: findById(id) ?: throw ... for the not-found case, and request.title ?: existing.title to keep the old value when an update field is omitted.

Every type that crosses the wire is @Serializable, which lets kotlinx.serialization generate JSON encoders at compile time (no reflection). The domain Task uses typed enums (TaskStatus, Priority) with sensible defaults, while the request/response DTOs use plain String fields and a toResponse() extension maps between them.

src/main/kotlin/com/example/taskapi/models/Task.kt
package com.example.taskapi.models
import kotlinx.serialization.Serializable
import java.util.UUID
@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 TaskResponse(
val id: String,
val title: String,
val description: String,
val status: String,
val priority: String,
val createdAt: String,
val updatedAt: String
)
@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
)

Serialization, Validation, and ErrorHandling — the plugins

Section titled “Serialization, Validation, and ErrorHandling — the plugins”

Ktor’s “middleware” is plugins you install. Each gets its own fun Application.configureX() extension so module() stays readable.

Serialization wires kotlinx.serialization into content negotiation — the explicit version of Spring Boot auto-configuring Jackson:

src/main/kotlin/com/example/taskapi/plugins/Serialization.kt
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = false
ignoreUnknownKeys = true
encodeDefaults = true
})
}
}

Validation is code-based, not annotation-based. Where Spring puts @field:NotBlank on the DTO, Ktor registers a validate<T> { } block that returns ValidationResult.Valid or ValidationResult.Invalid(errors). A failure throws a RequestValidationException, which StatusPages then renders as a 400.

// Zod
const schema = z.object({
title: z.string().min(1).max(200),
});
schema.parse(req.body);

ErrorHandling is where the StatusPages plugin maps each exception type to an HTTP response. This is the single place that knows about status codes, so handlers elsewhere can just throw and stay clean — the Ktor analogue of one @RestControllerAdvice class.

src/main/kotlin/com/example/taskapi/plugins/ErrorHandling.kt
@Serializable
data class ErrorResponse(
val status: Int,
val error: String,
val message: String
)
class TaskNotFoundException(val taskId: String) :
RuntimeException("Task not found: $taskId")
class InvalidTaskStatusException(val status: String) :
RuntimeException("Invalid task status: $status. Valid values: TODO, IN_PROGRESS, DONE")
fun Application.configureErrorHandling() {
install(StatusPages) {
exception<RequestValidationException> { call, cause ->
call.respond(HttpStatusCode.BadRequest,
ErrorResponse(400, "Validation Failed", cause.reasons.joinToString("; ")))
}
exception<TaskNotFoundException> { call, cause ->
call.respond(HttpStatusCode.NotFound,
ErrorResponse(404, "Not Found", cause.message ?: "Resource not found"))
}
exception<InvalidTaskStatusException> { call, cause ->
call.respond(HttpStatusCode.BadRequest,
ErrorResponse(400, "Bad Request", cause.message ?: "Invalid input"))
}
exception<kotlinx.serialization.SerializationException> { call, cause ->
call.respond(HttpStatusCode.BadRequest,
ErrorResponse(400, "Bad Request", "Invalid request body: ${cause.message}"))
}
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"))
}
}
}

Spring auto-discovers a @Repository and creates a singleton for you. Koin is explicit: you declare a module { } and register the binding with single<TaskRepository> { InMemoryTaskRepository() }. single means singleton scope — one instance for the app’s lifetime, the same default Spring gives @Repository.

src/main/kotlin/com/example/taskapi/di/AppModule.kt
val appModule = module {
// single = singleton scope (one instance for the entire application lifetime)
// Equivalent to Spring's default singleton scope for @Repository beans.
single<TaskRepository> { InMemoryTaskRepository() }
}

TaskRepository.kt — framework-independent data access

Section titled “TaskRepository.kt — framework-independent data access”

The key teaching point: this interface is identical to the Spring Boot version. Your data-access contract doesn’t depend on the web framework. Only the registration differs (a Koin single { } here, a @Repository annotation there). The in-memory implementation uses a ConcurrentHashMap for thread safety.

src/main/kotlin/com/example/taskapi/repository/TaskRepository.kt
interface TaskRepository {
fun findAll(): List<Task>
fun findById(id: String): Task?
fun save(task: Task): Task
fun deleteById(id: String): Boolean
fun findByStatus(status: TaskStatus): List<Task>
fun search(query: String): List<Task>
fun count(): Int
}
class InMemoryTaskRepository : TaskRepository {
private val tasks = ConcurrentHashMap<String, Task>()
override fun findAll(): List<Task> =
tasks.values.sortedByDescending { it.createdAt }
override fun findById(id: String): Task? = tasks[id]
override fun save(task: Task): Task {
tasks[task.id] = task
return task
}
override fun deleteById(id: String): Boolean = tasks.remove(id) != null
override fun findByStatus(status: TaskStatus): List<Task> =
tasks.values.filter { it.status == status }.sortedByDescending { it.createdAt }
override fun search(query: String): List<Task> =
tasks.values.filter { task ->
task.title.contains(query, ignoreCase = true) ||
task.description.contains(query, ignoreCase = true)
}.sortedByDescending { it.createdAt }
override fun count(): Int = tasks.size
}

TaskRoutesTest.kt — integration tests with testApplication

Section titled “TaskRoutesTest.kt — integration tests with testApplication”

Ktor tests are noticeably lighter than Spring’s @SpringBootTest + MockMvc: no annotations, no application-context startup, no @Autowired. You call testApplication { application { module() } } — the same module() your real server uses — and hit it with a built-in client. Each test starts with an empty in-memory repository, so tests stay isolated.

src/test/kotlin/com/example/taskapi/TaskRoutesTest.kt
class TaskRoutesTest {
// Helper for a JSON-aware test client
private fun ApplicationTestBuilder.jsonClient() = createClient {
install(ContentNegotiation) { json() }
}
@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().trim())
}
@Test
fun `POST should create a task with defaults`() = testApplication {
application { module() }
val jsonClient = jsonClient()
val response = jsonClient.post("/api/tasks") {
contentType(ContentType.Application.Json)
setBody(CreateTaskRequest(title = "Test task"))
}
assertEquals(HttpStatusCode.Created, response.status)
val task = response.body<TaskResponse>()
assertEquals("Test task", task.title)
assertEquals("TODO", task.status)
assertEquals("MEDIUM", task.priority)
}
@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)
assertTrue(response.bodyAsText().contains("Validation Failed"))
}
@Test
fun `GET by id should return 404 for nonexistent task`() = testApplication {
application { module() }
val response = client.get("/api/tasks/nonexistent-id")
assertEquals(HttpStatusCode.NotFound, response.status)
assertTrue(response.bodyAsText().contains("Not Found"))
}
}

The full suite covers every endpoint — list, create (with defaults, all fields, Location header, validation failures), get-by-id, update, delete, search, and status filtering. The pattern is always the same: spin up module(), exercise a route, assert the status and body.

This API uses in-memory storage, so there’s no database to start — no docker compose prerequisite for this exercise (the Postgres version comes later, in Module 10).

  1. Build and run the server:

    Terminal window
    ./gradlew run
  2. In another terminal, create a task:

    Terminal window
    curl -X POST http://localhost:8080/api/tasks \
    -H "Content-Type: application/json" \
    -d '{"title": "Learn Ktor", "description": "Complete module 09", "priority": "HIGH"}'
  3. List, search, and filter:

    Terminal window
    curl http://localhost:8080/api/tasks
    curl "http://localhost:8080/api/tasks/search?q=Ktor"
    curl http://localhost:8080/api/tasks/status/TODO
  4. Run the integration tests:

    Terminal window
    ./gradlew test
  5. Or build a self-contained fat JAR (thanks to the Ktor Gradle plugin):

    Terminal window
    ./gradlew buildFatJar
    java -jar build/libs/task-api-ktor-all.jar