Task Management CRUD API (Spring Boot)
Build a complete REST API for task management using Spring Boot 3.x with Kotlin — full CRUD, request validation, and centralized error handling, organized into the classic controller → service → repository layers. Module 09 builds the same API in Ktor, so you can compare the two frameworks head to head.
If you’ve written an Express or NestJS app, the shape will feel familiar:
annotations replace decorators, constructor injection replaces manual wiring, and
the type system does the work that Zod/io-ts does in TS.
Requirements
Section titled “Requirements”- Full CRUD: create, read, update, delete tasks.
- Input validation with Jakarta Bean Validation (
@NotBlank,@Size,@Pattern). - Global error handling with
@RestControllerAdvice— notry/catchscattered across controllers. - Type-safe configuration with
@ConfigurationProperties. - Health check and metrics via Spring Actuator.
- In-memory storage — no database needed for this exercise.
- Unit and integration tests.
API endpoints
Section titled “API 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 |
GET | /actuator/health | Health check |
GET | /actuator/metrics | Available metrics |
The worked solution
Section titled “The worked solution”A standard layered Spring Boot project. The package layout mirrors the responsibilities — one folder per layer:
Directorytask-api-spring/
- build.gradle.kts Spring Boot plugins + starters
Directorysrc/main/kotlin/com/example/taskapi/
- TaskApiApplication.kt entry point
- config/AppProperties.kt type-safe configuration
Directorycontroller/ TaskController.kt REST endpoints
- …
- dto/TaskDtos.kt request/response DTOs with validation
- exception/Exceptions.kt custom exception classes
- exception/GlobalExceptionHandler.kt centralized error handling
- model/Task.kt domain model
- repository/TaskRepository.kt in-memory data access
- service/TaskService.kt business logic
- src/main/resources/application.yml ports, actuator, app config
Directorysrc/test/kotlin/com/example/taskapi/ unit + integration tests
- …
The request flows down through the layers and the response bubbles back up:
flowchart LR Client["HTTP client"] -->|"JSON"| C["TaskController @RestController"] C -->|"DTO"| S["TaskService @Service"] S -->|"Task"| R["TaskRepository in-memory"] R --> S S -->|"TaskResponse"| C C -->|"JSON"| Client C -.->|"throws"| EH["GlobalExceptionHandler @RestControllerAdvice"] S -.->|"throws"| EH
We’ll show the key files — entity, DTOs, repository, service, controller, and the exception handler. The build file and tests round it out.
model/Task.kt — the domain model
Section titled “model/Task.kt — the domain model”A Kotlin data class with defaults is the whole entity. There’s no ORM here; the
id defaults to a fresh UUID and the timestamps default to now, so creating a task
is just Task(title = "…"). The two enums model the closed sets of valid states.
package com.example.taskapi.model
import java.time.Instantimport java.util.UUID
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: Instant = Instant.now(), val updatedAt: Instant = Instant.now())
enum class TaskStatus { TODO, IN_PROGRESS, DONE}
enum class Priority { LOW, MEDIUM, HIGH, CRITICAL}dto/TaskDtos.kt — request validation + response shaping
Section titled “dto/TaskDtos.kt — request validation + response shaping”The DTOs are where validation lives. The @field: annotations attach Jakarta Bean
Validation rules to the constructor properties; Spring runs them automatically when
the controller method is marked @Valid. Note CreateTaskRequest has non-null
fields (everything required up front) while UpdateTaskRequest makes every field
nullable — a null means “leave this field alone.” The Task.toResponse()
extension function maps the domain model to the wire DTO, turning enums into their
.name strings.
package com.example.taskapi.dto
import com.example.taskapi.model.Taskimport jakarta.validation.constraints.NotBlankimport jakarta.validation.constraints.Patternimport jakarta.validation.constraints.Sizeimport java.time.Instant
data class CreateTaskRequest( @field:NotBlank(message = "Title is required") @field:Size(min = 1, max = 200, message = "Title must be 1-200 characters") val title: String,
@field:Size(max = 2000, message = "Description must be at most 2000 characters") val description: String = "",
@field:Pattern( regexp = "LOW|MEDIUM|HIGH|CRITICAL", message = "Priority must be one of: LOW, MEDIUM, HIGH, CRITICAL" ) val priority: String = "MEDIUM")
data class UpdateTaskRequest( @field:Size(min = 1, max = 200, message = "Title must be 1-200 characters") val title: String? = null,
@field:Size(max = 2000, message = "Description must be at most 2000 characters") val description: String? = null,
@field:Pattern( regexp = "TODO|IN_PROGRESS|DONE", message = "Status must be one of: TODO, IN_PROGRESS, DONE" ) val status: String? = null,
@field:Pattern( regexp = "LOW|MEDIUM|HIGH|CRITICAL", message = "Priority must be one of: LOW, MEDIUM, HIGH, CRITICAL" ) val priority: String? = null)
data class TaskResponse( val id: String, val title: String, val description: String, val status: String, val priority: String, val createdAt: Instant, val updatedAt: Instant)
fun Task.toResponse() = TaskResponse( id = id, title = title, description = description, status = status.name, priority = priority.name, createdAt = createdAt, updatedAt = updatedAt)repository/TaskRepository.kt — data access behind an interface
Section titled “repository/TaskRepository.kt — data access behind an interface”The repository is an interface plus an in-memory implementation backed by a
ConcurrentHashMap. Coding the service against the interface (not the concrete
class) means you could swap in a JDBC or JPA implementation later without touching
the service. findById returns a nullable Task? — the Kotlin equivalent of
Optional<Task>, and the service handles the null.
package com.example.taskapi.repository
import com.example.taskapi.model.Taskimport com.example.taskapi.model.TaskStatusimport org.springframework.stereotype.Repositoryimport java.util.concurrent.ConcurrentHashMap
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}
@Repositoryclass 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}service/TaskService.kt — business logic
Section titled “service/TaskService.kt — business logic”The service holds the rules. It’s a @Service with the repository injected through
the constructor (private val repository: TaskRepository) — Spring sees one
constructor and wires it automatically; no @Autowired needed. A few things worth
calling out:
findByIdandupdateuse the Elvis operator:repository.findById(id) ?: throw TaskNotFoundException(id). If the lookup returnsnull, throw — the global handler turns it into a 404.updateusesexisting.copy(...)withrequest.field ?: existing.fieldso anullin the request means “keep the current value.” That’s the partial-update pattern on an immutabledata class.parseStatusconverts a user string to theTaskStatusenum and translates theIllegalArgumentExceptionfromvalueOfinto a domain exception.
package com.example.taskapi.service
import com.example.taskapi.dto.CreateTaskRequestimport com.example.taskapi.dto.TaskResponseimport com.example.taskapi.dto.UpdateTaskRequestimport com.example.taskapi.dto.toResponseimport com.example.taskapi.exception.InvalidTaskStatusExceptionimport com.example.taskapi.exception.TaskNotFoundExceptionimport com.example.taskapi.model.Priorityimport com.example.taskapi.model.Taskimport com.example.taskapi.model.TaskStatusimport com.example.taskapi.repository.TaskRepositoryimport org.slf4j.LoggerFactoryimport org.springframework.stereotype.Serviceimport java.time.Instant
@Serviceclass TaskService(private val repository: TaskRepository) {
private val logger = LoggerFactory.getLogger(javaClass)
fun findAll(): List<TaskResponse> = repository.findAll().map { it.toResponse() }
fun findById(id: String): TaskResponse { val task = repository.findById(id) ?: throw TaskNotFoundException(id) return task.toResponse() }
fun create(request: CreateTaskRequest): TaskResponse { val task = Task( title = request.title, description = request.description, priority = Priority.valueOf(request.priority) ) val saved = repository.save(task) logger.info("Created task: id={}, title={}", saved.id, saved.title) return saved.toResponse() }
fun update(id: String, request: UpdateTaskRequest): TaskResponse { val existing = repository.findById(id) ?: throw TaskNotFoundException(id)
val updated = existing.copy( title = request.title ?: existing.title, description = request.description ?: existing.description, status = request.status?.let { parseStatus(it) } ?: existing.status, priority = request.priority?.let { Priority.valueOf(it) } ?: existing.priority, updatedAt = Instant.now() ) val saved = repository.save(updated) logger.info("Updated task: id={}", saved.id) return saved.toResponse() }
fun delete(id: String) { if (!repository.deleteById(id)) { throw TaskNotFoundException(id) } logger.info("Deleted task: id={}", id) }
fun findByStatus(status: String): List<TaskResponse> { val taskStatus = parseStatus(status) return repository.findByStatus(taskStatus).map { it.toResponse() } }
fun search(query: String): List<TaskResponse> = repository.search(query).map { it.toResponse() }
private fun parseStatus(status: String): TaskStatus { return try { TaskStatus.valueOf(status.uppercase()) } catch (e: IllegalArgumentException) { throw InvalidTaskStatusException(status) } }}controller/TaskController.kt — the HTTP layer
Section titled “controller/TaskController.kt — the HTTP layer”The controller is thin: it maps HTTP verbs and paths to service calls and nothing
else. @RestController means every return value is serialized to JSON (no view
resolution). @RequestMapping("/api/tasks") prefixes every route. The interesting
return type is ResponseEntity<TaskResponse> on create — it lets you set the
201 Created status and a Location header. The other methods just return the
DTO directly (Spring defaults to 200 OK), and deleteTask uses
@ResponseStatus(HttpStatus.NO_CONTENT) for a 204.
package com.example.taskapi.controller
import com.example.taskapi.dto.CreateTaskRequestimport com.example.taskapi.dto.TaskResponseimport com.example.taskapi.dto.UpdateTaskRequestimport com.example.taskapi.service.TaskServiceimport jakarta.validation.Validimport org.springframework.http.HttpStatusimport org.springframework.http.ResponseEntityimport org.springframework.web.bind.annotation.*import java.net.URI
@RestController@RequestMapping("/api/tasks")class TaskController(private val taskService: TaskService) {
@GetMapping fun getAllTasks(): List<TaskResponse> = taskService.findAll()
@GetMapping("/{id}") fun getTask(@PathVariable id: String): TaskResponse = taskService.findById(id)
@PostMapping fun createTask(@Valid @RequestBody request: CreateTaskRequest): ResponseEntity<TaskResponse> { val task = taskService.create(request) return ResponseEntity .created(URI.create("/api/tasks/${task.id}")) .body(task) }
@PutMapping("/{id}") fun updateTask( @PathVariable id: String, @Valid @RequestBody request: UpdateTaskRequest ): TaskResponse = taskService.update(id, request)
@DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) fun deleteTask(@PathVariable id: String) { taskService.delete(id) }
@GetMapping("/status/{status}") fun getTasksByStatus(@PathVariable status: String): List<TaskResponse> = taskService.findByStatus(status)
@GetMapping("/search") fun searchTasks(@RequestParam q: String): List<TaskResponse> = taskService.search(q)}Notice what’s not here: no try/catch, no status-code juggling for missing
tasks. The controller assumes the happy path. When the service throws
TaskNotFoundException, the next file catches it.
exception/GlobalExceptionHandler.kt — one place for all errors
Section titled “exception/GlobalExceptionHandler.kt — one place for all errors”@RestControllerAdvice registers a class whose @ExceptionHandler methods run for
exceptions thrown anywhere in the request pipeline. Each handler maps an exception
type to an HTTP status and a consistent ErrorResponse body. The
MethodArgumentNotValidException handler is special — Spring throws it when a
@Valid DTO fails validation, and here we unpack the field errors into a
details map so the client sees exactly which fields were wrong.
package com.example.taskapi.exception
import org.slf4j.LoggerFactoryimport org.springframework.http.HttpStatusimport org.springframework.http.ResponseEntityimport org.springframework.web.bind.MethodArgumentNotValidExceptionimport org.springframework.web.bind.annotation.ExceptionHandlerimport org.springframework.web.bind.annotation.RestControllerAdviceimport java.time.Instant
data class ErrorResponse( val status: Int, val error: String, val message: String, val timestamp: Instant = Instant.now(), val details: Map<String, String>? = null)
@RestControllerAdviceclass GlobalExceptionHandler {
private val logger = LoggerFactory.getLogger(javaClass)
@ExceptionHandler(TaskNotFoundException::class) fun handleNotFound(ex: TaskNotFoundException): ResponseEntity<ErrorResponse> { return ResponseEntity.status(HttpStatus.NOT_FOUND).body( ErrorResponse( status = 404, error = "Not Found", message = ex.message ?: "Resource not found" ) ) }
@ExceptionHandler(MethodArgumentNotValidException::class) fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> { val details = ex.bindingResult.fieldErrors.associate { error -> error.field to (error.defaultMessage ?: "Invalid value") } return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( ErrorResponse( status = 400, error = "Validation Failed", message = "Request validation failed", details = details ) ) }
@ExceptionHandler(Exception::class) fun handleGeneric(ex: Exception): ResponseEntity<ErrorResponse> { logger.error("Unhandled exception", ex) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( ErrorResponse( status = 500, error = "Internal Server Error", message = "An unexpected error occurred" ) ) }}build.gradle.kts and configuration
Section titled “build.gradle.kts and configuration”The build pulls in the Spring Boot plugins and the web, actuator, and
validation starters. jackson-module-kotlin teaches Jackson to serialize Kotlin
data classes; kotlin("plugin.spring") makes Spring-managed classes open so the
framework can proxy them.
plugins { kotlin("jvm") version "2.1.0" kotlin("plugin.spring") version "2.1.0" id("org.springframework.boot") version "3.4.1" id("io.spring.dependency-management") version "1.1.7"}
group = "com.example"version = "1.0.0"
java { toolchain { languageVersion = JavaLanguageVersion.of(21) }}
repositories { mavenCentral()}
dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect")
developmentOnly("org.springframework.boot:spring-boot-devtools")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher")}Configuration lives in application.yml and is bound to the type-safe
AppProperties data class via @ConfigurationProperties(prefix = "app") plus
@ConfigurationPropertiesScan on the application class — so app.max-tasks-per-user
becomes AppProperties.maxTasksPerUser: Int, validated at startup instead of read
as a loose string.
server: port: 8080
app: name: Task Management API (Spring Boot) version: 1.0.0 max-tasks-per-user: 100
management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: alwaysRun it
Section titled “Run it”No database needed — storage is in-memory, so there’s nothing to spin up first.
-
Build and run the app:
Terminal window ./gradlew bootRun -
Create a task:
Terminal window curl -X POST http://localhost:8080/api/tasks \-H "Content-Type: application/json" \-d '{"title": "Learn Spring Boot", "description": "Complete module 08", "priority": "HIGH"}' -
List, search, and filter:
Terminal window curl http://localhost:8080/api/taskscurl "http://localhost:8080/api/tasks/search?q=Spring"curl http://localhost:8080/api/tasks/status/TODO -
Check the health endpoint:
Terminal window curl http://localhost:8080/actuator/health
Test it
Section titled “Test it”The solution ships unit tests for the service and integration tests for the
controller (using MockMvc from spring-boot-starter-test):
./gradlew test