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).
What you’ll build
Section titled “What you’ll build”- 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
RequestValidationplugin (code-based, not annotations) - Centralized error handling via the
StatusPagesplugin - Dependency injection with Koin (
module { single { } }) - JSON via
kotlinx.serialization(@Serializabledata classes) - In-memory storage behind the same repository interface as the Spring Boot exercise
- Integration tests with the
testApplication { }DSL
Endpoints
Section titled “Endpoints”| Method | Path | Description |
|---|---|---|
| GET | /api/tasks | List all tasks |
| GET | /api/tasks/{id} | Get a task by ID |
| POST | /api/tasks | Create 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=term | Search tasks by title/description |
Spring Boot vs Ktor at a glance
Section titled “Spring Boot vs Ktor at a glance”This table is the heart of the comparison — keep it handy as you read the code.
| Concept | Spring Boot (Module 08) | Ktor (this exercise) |
|---|---|---|
| Entry point | @SpringBootApplication + runApplication<>() | embeddedServer(Netty) { module() } |
| Routing | @RestController + @GetMapping | route("/api/tasks") { get { } } |
| DI | @Service, @Repository (auto-scanned) | Koin module { single { } } |
| JSON | Jackson (auto-configured) | kotlinx.serialization (@Serializable) |
| Validation | @field:NotBlank, @Valid | RequestValidation plugin (code-based) |
| Error handling | @RestControllerAdvice + @ExceptionHandler | StatusPages plugin |
| Testing | @SpringBootTest + MockMvc | testApplication { } DSL |
| Startup time | ~1-3 seconds | ~100-300ms |
| Dependencies | One starter pulls everything | Individual ktor-server-* deps |
The worked solution
Section titled “The worked solution”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.
build.gradle.kts
Section titled “build.gradle.kts”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.
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.
package com.example.taskapi
import com.example.taskapi.di.appModuleimport com.example.taskapi.plugins.configureCallLoggingimport com.example.taskapi.plugins.configureErrorHandlingimport com.example.taskapi.plugins.configureSerializationimport com.example.taskapi.plugins.configureValidationimport com.example.taskapi.routes.taskRoutesimport io.ktor.server.application.*import io.ktor.server.engine.*import io.ktor.server.netty.*import io.ktor.server.routing.*import org.koin.ktor.plugin.Koinimport 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()); // ≈ ContentNegotiationapp.use(morgan("dev")); // ≈ CallLoggingapp.use("/api/tasks", taskRouter);app.listen(8080);e := echo.New()e.Use(middleware.Logger()) // ≈ CallLoggingsetupRoutes(e)e.Start(":8080")embeddedServer(Netty, port = 8080) { install(ContentNegotiation) { json() } install(CallLogging) routing { taskRoutes() }}.start(wait = true)TaskRoutes.kt — the REST surface
Section titled “TaskRoutes.kt — the REST surface”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.
package com.example.taskapi.routes
import com.example.taskapi.models.*import com.example.taskapi.plugins.InvalidTaskStatusExceptionimport com.example.taskapi.plugins.TaskNotFoundExceptionimport com.example.taskapi.repository.TaskRepositoryimport 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 — likereq.bodyafterexpress.json()but type-checked, or Go’sc.Bind(&req).- The handlers don’t try/catch “not found” everywhere. They just throw
TaskNotFoundException, and theStatusPagesplugin turns it into a 404. That’s the Ktor equivalent of a Spring@ExceptionHandler. - Route ordering matters:
/searchand/status/{status}are declared before/{id}, otherwise Ktor would matchsearchas 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, andrequest.title ?: existing.titleto keep the old value when an update field is omitted.
Task.kt — model and DTOs
Section titled “Task.kt — model and DTOs”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.
package com.example.taskapi.models
import kotlinx.serialization.Serializableimport java.util.UUID
@Serializabledata class Task( val id: String = UUID.randomUUID().toString(), val title: String, val description: String = "", val status: TaskStatus = TaskStatus.TODO, val priority: Priority = Priority.MEDIUM, val createdAt: String = java.time.Instant.now().toString(), val updatedAt: String = java.time.Instant.now().toString())
@Serializableenum class TaskStatus { TODO, IN_PROGRESS, DONE }
@Serializableenum class Priority { LOW, MEDIUM, HIGH, CRITICAL }
@Serializabledata class TaskResponse( val id: String, val title: String, val description: String, val status: String, val priority: String, val createdAt: String, val updatedAt: String)
@Serializabledata class CreateTaskRequest( val title: String, val description: String = "", val priority: String = "MEDIUM")
@Serializabledata class UpdateTaskRequest( val title: String? = null, val description: String? = null, val status: String? = null, val priority: String? = null)
fun Task.toResponse() = TaskResponse( id = id, title = title, description = description, status = status.name, priority = priority.name, createdAt = createdAt, updatedAt = updatedAt)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:
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.
// Zodconst schema = z.object({ title: z.string().min(1).max(200),});schema.parse(req.body);type CreateTaskRequest struct { Title string `validate:"required,min=1,max=200"`}// Ktor RequestValidation (code-based)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.priority.uppercase() !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)}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.
@Serializabledata 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")) } }}AppModule.kt — Koin DI
Section titled “AppModule.kt — Koin DI”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.
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.
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.
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.
Run it
Section titled “Run it”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).
-
Build and run the server:
Terminal window ./gradlew run -
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"}' -
List, search, and filter:
Terminal window curl http://localhost:8080/api/taskscurl "http://localhost:8080/api/tasks/search?q=Ktor"curl http://localhost:8080/api/tasks/status/TODO -
Run the integration tests:
Terminal window ./gradlew test -
Or build a self-contained fat JAR (thanks to the Ktor Gradle plugin):
Terminal window ./gradlew buildFatJarjava -jar build/libs/task-api-ktor-all.jar