Skip to content

Spring Boot REST APIs

Spring Boot is the JVM’s most widely used web framework — the rough equivalent of Express.js in TypeScript-land or chi/gin in Go, but with batteries included: auto-configuration, an embedded server, dependency injection, and production-ready monitoring out of the box. This module builds a REST API in Kotlin and maps every piece back to what you already know from Express and Go.

Spring Boot is an opinionated framework built on top of the Spring Framework. It provides auto-configuration, embedded servers, and production-ready features out of the box.

ConceptExpress.js (TS)Go (net/http + chi)Spring Boot (Kotlin)
FrameworkExpress / Fastifynet/http + chi/ginSpring Boot
ServerNode.js runtimeGo runtimeEmbedded Tomcat/Netty
Routingapp.get("/path", handler)r.Get("/path", handler)@GetMapping("/path")
Middlewareapp.use(middleware)r.Use(middleware)Filters / Interceptors
DI containertsyringe / InversifyJSwire / manualBuilt-in IoC container
Configdotenv / configenvconfig / Viperapplication.yml + @ConfigurationProperties
JSONBuilt-in JSON.parseencoding/jsonJackson (auto-configured)
Validationclass-validator / Joi / Zodmanual / go-playground/validatorJakarta Bean Validation
Health checksCustom or terminusCustom handlerActuator (built-in)
  • Massive ecosystem: Every library in Java-land works with Spring.
  • Production battle-tested: Used by Netflix, Amazon, Alibaba, and thousands of companies.
  • Kotlin-first support: Spring has official Kotlin extensions, coroutine support, null-safety integration.
  • Convention over configuration: Sensible defaults with escape hatches.
  • Hiring: Most JVM backend jobs use Spring Boot.

The easiest way to start a Spring Boot project is start.spring.io:

  1. Project: Gradle - Kotlin
  2. Language: Kotlin
  3. Spring Boot: 3.4.x
  4. Java: 21
  5. Dependencies: Spring Web, Spring Boot DevTools, Spring Boot Actuator

Or use the command line:

Terminal window
curl https://start.spring.io/starter.zip \
-d type=gradle-project-kotlin \
-d language=kotlin \
-d bootVersion=3.4.1 \
-d javaVersion=21 \
-d groupId=com.example \
-d artifactId=task-api \
-d name=task-api \
-d dependencies=web,devtools,actuator \
-o task-api.zip
unzip task-api.zip -d task-api
cd task-api
./gradlew bootRun
build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.spring") version "2.1.0" // Makes classes open for Spring proxies
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")
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict") // Strict null-safety for Spring annotations
}
}
tasks.withType<Test> {
useJUnitPlatform()
}

Key plugins explained:

PluginWhy
kotlin("plugin.spring")Makes @Component, @Service, etc. classes open (Spring uses class proxies)
org.springframework.bootAdds bootRun, bootJar tasks; manages Spring BOM
io.spring.dependency-managementAuto-manages versions for all Spring dependencies

Why kotlin-reflect? Spring uses reflection to instantiate beans, read annotations, and inject dependencies. Kotlin’s reflection library is required.

Why -Xjsr305=strict? Spring annotations carry nullability metadata. This flag makes the Kotlin compiler enforce them — preventing null-related bugs at compile time.

settings.gradle.kts
rootProject.name = "task-api"
src/main/kotlin/com/example/taskapi/TaskApiApplication.kt
package com.example.taskapi
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class TaskApiApplication
fun main(args: Array<String>) {
runApplication<TaskApiApplication>(*args)
}

What @SpringBootApplication does behind the scenes:

  • @Configuration — this class can define beans.
  • @EnableAutoConfiguration — Spring auto-configures based on classpath.
  • @ComponentScan — scans for @Component, @Service, etc. in this package and subpackages.

The TypeScript and Go equivalents of “boot a server and wire in JSON parsing”:

index.ts
import express from "express";
const app = express();
app.use(express.json()); // ≈ Jackson auto-configuration
app.listen(8080); // ≈ embedded Tomcat
  • Directorysrc/
    • Directorymain/
      • Directorykotlin/com/example/taskapi/
        • TaskApiApplication.kt entry point
        • Directorycontroller/ HTTP handlers
          • TaskController.kt
        • Directoryservice/ business logic
          • TaskService.kt
        • Directoryrepository/ data access
          • TaskRepository.kt
        • Directorymodel/ domain models
          • Task.kt
        • Directorydto/ request/response DTOs
          • CreateTaskRequest.kt
          • TaskResponse.kt
        • Directoryexception/ custom exceptions + handlers
          • Exceptions.kt
          • GlobalExceptionHandler.kt
        • Directoryconfig/ configuration classes
          • AppConfig.kt
      • Directoryresources/
        • application.yml configuration
    • Directorytest/
      • Directorykotlin/com/example/taskapi/
        • Directorycontroller/
          • TaskControllerTest.kt
        • Directoryservice/
          • TaskServiceTest.kt

A @RestController is a class whose methods handle HTTP requests; Spring auto-serializes whatever you return to JSON.

src/main/kotlin/com/example/taskapi/controller/HelloController.kt
package com.example.taskapi.controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class HelloController {
@GetMapping("/hello")
fun hello(): String = "Hello, World!"
@GetMapping("/hello/json")
fun helloJson(): Map<String, String> = mapOf("message" to "Hello, World!")
}

The same two routes in Express and Go:

app.get("/hello", (req, res) => {
res.send("Hello, World!");
});
app.get("/hello/json", (req, res) => {
res.json({ message: "Hello, World!" });
});

Run and test it:

Terminal window
./gradlew bootRun
Terminal window
curl http://localhost:8080/hello
# Hello, World!
curl http://localhost:8080/hello/json
# {"message":"Hello, World!"}
AnnotationPurposeExpress EquivalentGo Equivalent
@RestControllerMarks class as HTTP handlerThe router file itselfThe handler struct
@GetMapping("/path")Handle GET requestsapp.get("/path", ...)r.Get("/path", ...)
@PostMapping("/path")Handle POST requestsapp.post("/path", ...)r.Post("/path", ...)
@PutMapping("/path")Handle PUT requestsapp.put("/path", ...)r.Put("/path", ...)
@DeleteMapping("/path")Handle DELETE requestsapp.delete("/path", ...)r.Delete("/path", ...)
@PatchMapping("/path")Handle PATCH requestsapp.patch("/path", ...)r.Patch("/path", ...)
@RequestMapping("/api")Base path prefixRouter("/api")r.Route("/api", ...)

A @RequestMapping("/api/tasks") on the class prefixes every route. The taskService constructor parameter is injected by Spring — more on that below.

src/main/kotlin/com/example/taskapi/controller/TaskController.kt
package com.example.taskapi.controller
import com.example.taskapi.dto.CreateTaskRequest
import com.example.taskapi.dto.UpdateTaskRequest
import com.example.taskapi.dto.TaskResponse
import com.example.taskapi.service.TaskService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.net.URI
@RestController
@RequestMapping("/api/tasks")
class TaskController(
private val taskService: TaskService // Injected by Spring
) {
@GetMapping
fun getAllTasks(): List<TaskResponse> =
taskService.findAll()
@GetMapping("/{id}")
fun getTask(@PathVariable id: String): TaskResponse =
taskService.findById(id)
@PostMapping
fun createTask(@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,
@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)
}

The same router in Express and Go:

const router = express.Router();
router.get("/", (req, res) => {
res.json(taskService.findAll());
});
router.get("/:id", (req, res) => {
res.json(taskService.findById(req.params.id));
});
router.post("/", (req, res) => {
const task = taskService.create(req.body);
res.status(201).location(`/api/tasks/${task.id}`).json(task);
});
router.put("/:id", (req, res) => {
res.json(taskService.update(req.params.id, req.body));
});
router.delete("/:id", (req, res) => {
taskService.delete(req.params.id);
res.status(204).send();
});
app.use("/api/tasks", router);

Key Differences:

  • Spring automatically serializes return values to JSON (no res.json() needed).
  • @PathVariable extracts from the URL path (like req.params.id or chi.URLParam).
  • @RequestParam extracts query parameters (like req.query.q or r.URL.Query().Get("q")).
  • @RequestBody deserializes the JSON body to a Kotlin class (like req.body or json.NewDecoder).
  • ResponseEntity<T> gives you full control over status codes and headers.

@PathVariable binds a {segment} from the route to a function parameter. Spring coerces types for you — note userId: Long below.

/api/tasks/abc-123
@GetMapping("/{id}")
fun getTask(@PathVariable id: String): TaskResponse = ...
// /api/users/42/tasks/abc-123
@GetMapping("/users/{userId}/tasks/{taskId}")
fun getUserTask(
@PathVariable userId: Long,
@PathVariable taskId: String
): TaskResponse = ...

@RequestParam reads query-string values. Make a param optional with a nullable type (String?) plus required = false, or give it a defaultValue.

// /api/tasks?status=TODO&page=1&size=10
@GetMapping
fun getTasks(
@RequestParam(required = false) status: String?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): List<TaskResponse> = ...
// Express: req.query.status, req.query.page
// Go: r.URL.Query().Get("status"), r.URL.Query().Get("page")

DTOs (Data Transfer Objects) are plain data classes — like TypeScript interfaces for request and response shapes. Optional fields use defaults or nullable types.

data class CreateTaskRequest(
val title: String,
val description: String = "",
val priority: String = "MEDIUM"
)
data class UpdateTaskRequest(
val title: String? = null,
val description: String? = null,
val status: String? = null,
val priority: String? = null
)
data class TaskResponse(
val id: String,
val title: String,
val description: String,
val status: String,
val priority: String,
val createdAt: String,
val updatedAt: String
)
// Usage in controller
@PostMapping
fun createTask(@RequestBody request: CreateTaskRequest): ResponseEntity<TaskResponse> = ...

The same request shape in TypeScript and Go:

interface CreateTaskRequest {
title: string;
description?: string;
priority?: string;
}
// Express auto-parses with express.json() middleware
app.post("/api/tasks", (req, res) => {
const body: CreateTaskRequest = req.body;
// ...
});
@GetMapping("/protected")
fun protectedEndpoint(
@RequestHeader("Authorization") authHeader: String,
@RequestHeader("X-Request-Id", required = false) requestId: String?
): String = ...
// Express: req.headers["authorization"]
// Go: r.Header.Get("Authorization")

When you need to set status codes, headers, or return no body, return a ResponseEntity<T> instead of the raw value.

@PostMapping
fun createTask(@RequestBody request: CreateTaskRequest): ResponseEntity<TaskResponse> {
val task = taskService.create(request)
return ResponseEntity
.status(HttpStatus.CREATED) // 201
.header("X-Task-Id", task.id) // Custom header
.location(URI.create("/api/tasks/${task.id}")) // Location header
.body(task) // JSON body
}
// Return no body
@DeleteMapping("/{id}")
fun deleteTask(@PathVariable id: String): ResponseEntity<Void> {
taskService.delete(id)
return ResponseEntity.noContent().build() // 204 No Content
}
// Conditional response
@GetMapping("/{id}")
fun getTask(@PathVariable id: String): ResponseEntity<TaskResponse> {
val task = taskService.findByIdOrNull(id)
?: return ResponseEntity.notFound().build() // 404
return ResponseEntity.ok(task) // 200
}

The Express analogue chains status, headers, and body the same way:

app.post("/api/tasks", (req, res) => {
const task = taskService.create(req.body);
res
.status(201)
.header("X-Task-Id", task.id)
.location(`/api/tasks/${task.id}`)
.json(task);
});

Dependency injection (DI) is Spring’s core feature. If you’ve used tsyringe, InversifyJS, or Go’s wire, the concept is the same: instead of creating dependencies manually, the framework wires them together.

Here’s how a request flows through the layers Spring wires for you:

Request flow through layers
Rendering diagram…

Without DI, every class constructs its own dependencies, and the wiring spirals out of control as soon as those dependencies have dependencies of their own:

// You'd have to create everything manually
class TaskController {
private val repository = TaskRepository() // Hardcoded dependency
private val service = TaskService(repository)
// What if TaskRepository needs a DataSource?
// What if TaskService needs a Logger, a Cache, a Validator?
// Manual wiring gets out of hand fast.
}

With Spring DI, you declare dependencies as constructor parameters and the container builds the graph for you:

// Spring creates and wires everything automatically
@RestController
class TaskController(
private val taskService: TaskService // Spring injects this
)
@Service
class TaskService(
private val taskRepository: TaskRepository // Spring injects this too
)
@Repository
class TaskRepository // Spring creates a singleton of this
AnnotationPurposeWhen to Use
@ComponentGeneric Spring-managed beanGeneral purpose
@ServiceBusiness logic layerServices, use cases
@RepositoryData access layerDatabase operations
@Controller / @RestControllerHTTP layerRequest handlers
@ConfigurationConfiguration/factoryBean definitions, config

These are all functionally the same (@Component under the hood). The different names exist for clarity and convention.

In Kotlin, constructor injection is the default — no @Autowired needed. Spring reads the constructor, finds beans matching each parameter type, and passes them in.

@Service
class TaskService(
private val repository: TaskRepository,
private val notificationService: NotificationService,
private val clock: Clock
) {
fun create(request: CreateTaskRequest): TaskResponse {
// use repository, notificationService, clock
}
}

The same wiring in TypeScript and Go is explicit:

@injectable()
class TaskService {
constructor(
@inject("TaskRepository") private repository: TaskRepository,
@inject("NotificationService") private notificationService: NotificationService
) {}
}

When you need to configure a third-party class or create beans with custom logic, declare @Bean factory methods in a @Configuration class:

@Configuration
class AppConfig {
@Bean
fun objectMapper(): ObjectMapper {
return jacksonObjectMapper().apply {
registerModule(JavaTimeModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
setSerializationInclusion(JsonInclude.Include.NON_NULL)
}
}
@Bean
fun clock(): Clock = Clock.systemUTC()
@Bean
fun restClient(): RestClient {
return RestClient.builder()
.baseUrl("https://api.example.com")
.defaultHeader("Accept", "application/json")
.build()
}
}

@Profile selects which bean is active per environment — for example, a local database in dev and a pooled production database in prod:

@Configuration
class DataSourceConfig {
@Bean
@Profile("dev")
fun devDataSource(): DataSource {
// In-memory or local database for development
return HikariDataSource().apply {
jdbcUrl = "jdbc:postgresql://localhost:5432/kotlin_course"
username = "dev"
password = "dev"
}
}
@Bean
@Profile("prod")
fun prodDataSource(): DataSource {
// Production database with connection pooling
return HikariDataSource().apply {
jdbcUrl = System.getenv("DATABASE_URL")
username = System.getenv("DATABASE_USER")
password = System.getenv("DATABASE_PASSWORD")
maximumPoolSize = 20
}
}
}

Depend on an interface, not a concrete class. Spring injects whichever implementation is registered, and tests can swap in a fake without touching the service:

// Define an interface
interface TaskRepository {
fun findAll(): List<Task>
fun findById(id: String): Task?
fun save(task: Task): Task
fun deleteById(id: String)
}
// Production implementation
@Repository
class InMemoryTaskRepository : TaskRepository {
private val tasks = mutableMapOf<String, Task>()
override fun findAll(): List<Task> = tasks.values.toList()
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) { tasks.remove(id) }
}
// Service depends on the interface, not the implementation
@Service
class TaskService(
private val repository: TaskRepository // Spring injects InMemoryTaskRepository
) {
fun findAll(): List<TaskResponse> =
repository.findAll().map { it.toResponse() }
}

Spring Boot reads application.yml (or application.properties) from src/main/resources/:

src/main/resources/application.yml
server:
port: 8080 # Server port (default: 8080)
spring:
application:
name: task-api # Application name
jackson:
default-property-inclusion: non_null
serialization:
write-dates-as-timestamps: false
app:
name: Task Management API
version: 1.0.0
max-tasks-per-user: 100
features:
notifications: true
audit-log: false
logging:
level:
root: INFO
com.example.taskapi: DEBUG # Debug logging for our code
org.springframework.web: DEBUG # Debug logging for HTTP

The TypeScript and Go equivalents lean on environment variables:

.env
PORT=8080
APP_NAME="Task Management API"
MAX_TASKS_PER_USER=100

Type-Safe Configuration with @ConfigurationProperties

Section titled “Type-Safe Configuration with @ConfigurationProperties”

Instead of reading config values as strings, bind them to a Kotlin data class:

src/main/kotlin/com/example/taskapi/config/AppProperties.kt
package com.example.taskapi.config
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "app")
data class AppProperties(
val name: String,
val version: String,
val maxTasksPerUser: Int = 100,
val features: Features = Features()
) {
data class Features(
val notifications: Boolean = true,
val auditLog: Boolean = false
)
}

Enable scanning in your application class with @ConfigurationPropertiesScan:

@SpringBootApplication
@ConfigurationPropertiesScan // Scans for @ConfigurationProperties classes
class TaskApiApplication

Then inject it anywhere, fully typed:

@Service
class TaskService(
private val repository: TaskRepository,
private val appProperties: AppProperties
) {
fun create(request: CreateTaskRequest): TaskResponse {
val userTaskCount = repository.countByUser(request.userId)
if (userTaskCount >= appProperties.maxTasksPerUser) {
throw TaskLimitExceededException("Max ${appProperties.maxTasksPerUser} tasks per user")
}
// ...
}
}

Profiles let you have different configurations for different environments. A single application.yml can hold profile-specific sections separated by ---:

# src/main/resources/application.yml (default)
server:
port: 8080
spring:
profiles:
active: dev # Default profile
---
# src/main/resources/application-dev.yml
app:
name: "Task API (DEV)"
logging:
level:
com.example: DEBUG
---
# src/main/resources/application-prod.yml
server:
port: 9090
app:
name: "Task API"
logging:
level:
com.example: WARN

Activate a profile:

Terminal window
# Via environment variable
SPRING_PROFILES_ACTIVE=prod ./gradlew bootRun
# Via command line
./gradlew bootRun --args='--spring.profiles.active=prod'
# Via JVM property
./gradlew bootRun -Dspring.profiles.active=prod

The TypeScript and Go equivalents flip an environment variable:

Terminal window
NODE_ENV=production node dist/index.js

Spring Boot automatically maps environment variables to config properties. The pattern ${ENV_VAR:default} reads the environment variable or falls back to the default:

application.yml
app:
api-key: ${API_KEY:default-key} # ${ENV_VAR:default}
db-url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/kotlin_course}

Without proper error handling, Spring returns generic 500 errors with stack traces. You want consistent, structured error responses instead.

Start with custom exception classes that carry the failure’s meaning:

src/main/kotlin/com/example/taskapi/exception/Exceptions.kt
package com.example.taskapi.exception
class TaskNotFoundException(val taskId: String) :
RuntimeException("Task not found: $taskId")
class TaskLimitExceededException(message: String) :
RuntimeException(message)
class InvalidTaskStatusException(val status: String) :
RuntimeException("Invalid task status: $status. Valid values: TODO, IN_PROGRESS, DONE")

Then a @RestControllerAdvice catches exceptions from every controller and maps each to a structured response:

src/main/kotlin/com/example/taskapi/exception/GlobalExceptionHandler.kt
package com.example.taskapi.exception
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import 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
)
@RestControllerAdvice
class 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(TaskLimitExceededException::class)
fun handleLimitExceeded(ex: TaskLimitExceededException): ResponseEntity<ErrorResponse> {
return ResponseEntity.status(HttpStatus.CONFLICT).body(
ErrorResponse(
status = 409,
error = "Conflict",
message = ex.message ?: "Limit exceeded"
)
)
}
@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"
)
)
}
}

A 404 response then looks like:

{
"status": 404,
"error": "Not Found",
"message": "Task not found: abc-123",
"timestamp": "2026-02-10T12:00:00Z",
"details": null
}

The same global handling in Express and Go:

// Error middleware in Express
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" });
});

Key Differences:

  • Express: error middleware is a function with an (err, req, res, next) signature.
  • Go: you typically handle errors at each handler, or use panic/recover.
  • Spring: @RestControllerAdvice catches exceptions from ALL controllers globally.

Spring Boot integrates with Jakarta Bean Validation (formerly javax.validation), included via spring-boot-starter-validation. You add constraint annotations to your DTO fields.

src/main/kotlin/com/example/taskapi/dto/CreateTaskRequest.kt
package com.example.taskapi.dto
import jakarta.validation.constraints.*
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"
)

Activate validation by adding @Valid to the @RequestBody parameter:

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

When validation fails, Spring throws MethodArgumentNotValidException, which the global exception handler above turns into:

{
"status": 400,
"error": "Validation Failed",
"message": "Request validation failed",
"timestamp": "2026-02-10T12:00:00Z",
"details": {
"title": "Title is required",
"priority": "Priority must be one of: LOW, MEDIUM, HIGH, CRITICAL"
}
}
AnnotationPurposeTypeScript (class-validator)Go (validator)
@NotBlankNot null/empty/whitespace@IsNotEmpty()validate:"required"
@NotNullNot null@IsDefined()validate:"required"
@Size(min, max)String/collection length@Length(min, max)validate:"min=1,max=200"
@Min / @MaxNumber range@Min() / @Max()validate:"min=0,max=100"
@EmailValid email format@IsEmail()validate:"email"
@Pattern(regexp)Regex match@Matches(regex)validate:"oneof=A B C"
@PositiveNumber > 0@IsPositive()validate:"gt=0"

For domain-specific rules, define your own constraint annotation plus a ConstraintValidator:

// Define annotation
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [NoSwearWordsValidator::class])
annotation class NoSwearWords(
val message: String = "Contains inappropriate language",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = []
)
// Define validator logic
class NoSwearWordsValidator : ConstraintValidator<NoSwearWords, String> {
private val blocked = setOf("badword1", "badword2") // Real list would be longer
override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean {
if (value == null) return true
return blocked.none { value.lowercase().contains(it) }
}
}
// Use it
data class CreateTaskRequest(
@field:NotBlank
@field:NoSwearWords
val title: String
)

Jackson is Spring Boot’s default JSON library. The jackson-module-kotlin makes it work cleanly with Kotlin data classes — it deserializes into data class constructors (no default constructor needed), handles nullable types, and supports default parameter values.

Most tuning happens declaratively in application.yml:

application.yml
spring:
jackson:
default-property-inclusion: non_null # Skip null fields in JSON
serialization:
write-dates-as-timestamps: false # ISO-8601 dates, not epoch millis
indent-output: false # No pretty-printing in production
deserialization:
fail-on-unknown-properties: false # Ignore extra fields in requests

Or programmatically with a customizer bean:

@Configuration
class JacksonConfig {
@Bean
fun jacksonCustomizer(): Jackson2ObjectMapperBuilderCustomizer {
return Jackson2ObjectMapperBuilderCustomizer { builder ->
builder
.serializationInclusion(JsonInclude.Include.NON_NULL)
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
}
}
}

Data classes work out of the box — missing JSON fields fall back to Kotlin defaults:

data class CreateTaskRequest(
val title: String,
val description: String = "", // Default values work
val priority: String = "MEDIUM" // If "priority" is missing from JSON, uses "MEDIUM"
)

Enums serialize to their name by default; use @JsonProperty for custom values:

enum class TaskStatus {
TODO, IN_PROGRESS, DONE
}
// Jackson serializes as: "TODO", "IN_PROGRESS", "DONE" by default
// If you want custom JSON values:
enum class Priority {
@JsonProperty("low") LOW,
@JsonProperty("medium") MEDIUM,
@JsonProperty("high") HIGH,
@JsonProperty("critical") CRITICAL
}

Sealed classes can carry a discriminator with @JsonTypeInfo and @JsonSubTypes:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(
JsonSubTypes.Type(value = NotificationEvent.TaskCreated::class, name = "task_created"),
JsonSubTypes.Type(value = NotificationEvent.TaskCompleted::class, name = "task_completed")
)
sealed class NotificationEvent {
data class TaskCreated(val taskId: String, val title: String) : NotificationEvent()
data class TaskCompleted(val taskId: String, val completedBy: String) : NotificationEvent()
}
// Serializes as:
// {"type": "task_created", "taskId": "abc", "title": "My Task"}

Use @JsonIgnore to hide fields and @JsonProperty to rename them:

data class User(
val id: String,
val email: String,
@JsonIgnore val passwordHash: String // Never include in JSON output
)
data class TaskResponse(
val id: String,
val title: String,
@JsonProperty("created_at") val createdAt: Instant, // snake_case in JSON
@JsonProperty("updated_at") val updatedAt: Instant
)

Spring Boot Actuator provides production-ready monitoring endpoints out of the box. Enable it with the starter dependency and choose what to expose in config:

application.yml
management:
endpoints:
web:
exposure:
include: health,info,metrics,env # Which endpoints to expose
endpoint:
health:
show-details: always # Show health check details
info:
app:
name: Task Management API
version: 1.0.0
description: CRUD API for task management
Terminal window
# Health check -- is the app running?
curl http://localhost:8080/actuator/health
# {"status":"UP","components":{"diskSpace":{"status":"UP"},"ping":{"status":"UP"}}}
# App info
curl http://localhost:8080/actuator/info
# {"app":{"name":"Task Management API","version":"1.0.0"}}
# All metrics
curl http://localhost:8080/actuator/metrics
# {"names":["jvm.memory.used","http.server.requests","process.uptime",...]}
# Specific metric
curl http://localhost:8080/actuator/metrics/jvm.memory.used
# {"name":"jvm.memory.used","measurements":[{"statistic":"VALUE","value":1.234E8}]}
# Environment properties
curl http://localhost:8080/actuator/env

Implement HealthIndicator to plug your own checks into /actuator/health:

@Component
class TaskRepositoryHealthIndicator(
private val repository: TaskRepository
) : HealthIndicator {
override fun health(): Health {
return try {
val count = repository.count()
Health.up()
.withDetail("taskCount", count)
.build()
} catch (ex: Exception) {
Health.down(ex).build()
}
}
}

The Express and Go equivalents are hand-built endpoints:

// Using @godaddy/terminus
const healthChecks = {
"/healthcheck": async () => {
const count = await taskRepository.count();
return { taskCount: count };
}
};
createTerminus(server, { healthChecks });

Key difference: in Express/Go you hand-build health endpoints. In Spring, Actuator provides them with a standard format, auto-discovers health indicators, and integrates with monitoring tools like Prometheus and Kubernetes.

The spring-boot-starter-test dependency brings JUnit 5, AssertJ, Mockito, and Spring’s test utilities. Two layers matter most: fast unit tests with no Spring context, and integration tests that boot the app.

A service test needs no Spring context — construct instances by hand:

src/test/kotlin/com/example/taskapi/service/TaskServiceTest.kt
package com.example.taskapi.service
import com.example.taskapi.dto.CreateTaskRequest
import com.example.taskapi.exception.TaskNotFoundException
import com.example.taskapi.repository.InMemoryTaskRepository
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class TaskServiceTest {
private lateinit var service: TaskService
@BeforeEach
fun setUp() {
// No Spring context needed -- just create instances manually
val repository = InMemoryTaskRepository()
service = TaskService(repository)
}
@Test
fun `should create a task`() {
val request = CreateTaskRequest(title = "Test task", description = "A test")
val result = service.create(request)
assertNotNull(result.id)
assertEquals("Test task", result.title)
assertEquals("TODO", result.status)
}
@Test
fun `should throw when task not found`() {
assertThrows<TaskNotFoundException> {
service.findById("nonexistent")
}
}
}

An integration test boots the app and exercises HTTP through MockMvc:

src/test/kotlin/com/example/taskapi/controller/TaskControllerTest.kt
package com.example.taskapi.controller
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.*
@SpringBootTest
@AutoConfigureMockMvc
class TaskControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Test
fun `POST should create a task`() {
mockMvc.post("/api/tasks") {
contentType = MediaType.APPLICATION_JSON
content = """{"title": "Test task", "description": "Integration test"}"""
}.andExpect {
status { isCreated() }
jsonPath("$.title") { value("Test task") }
jsonPath("$.status") { value("TODO") }
jsonPath("$.id") { isNotEmpty() }
}
}
@Test
fun `POST should return 400 for invalid request`() {
mockMvc.post("/api/tasks") {
contentType = MediaType.APPLICATION_JSON
content = """{"title": ""}"""
}.andExpect {
status { isBadRequest() }
}
}
@Test
fun `GET should return 404 for nonexistent task`() {
mockMvc.get("/api/tasks/nonexistent-id")
.andExpect {
status { isNotFound() }
jsonPath("$.error") { value("Not Found") }
}
}
}

The same HTTP tests in TypeScript and Go:

import request from "supertest";
import { app } from "../src/app";
describe("POST /api/tasks", () => {
it("should create a task", async () => {
const res = await request(app)
.post("/api/tasks")
.send({ title: "Test task" })
.expect(201);
expect(res.body.title).toBe("Test task");
});
});

Servlet filters are the Spring equivalent of Express middleware or Go middleware — they run before and after the controller for cross-cutting concerns like logging.

@Component
@Order(1) // Filter execution order
class RequestLoggingFilter : OncePerRequestFilter() {
private val logger = LoggerFactory.getLogger(javaClass)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val start = System.currentTimeMillis()
val requestId = request.getHeader("X-Request-Id") ?: UUID.randomUUID().toString()
// Add request ID to response
response.setHeader("X-Request-Id", requestId)
try {
filterChain.doFilter(request, response) // Pass to next filter/controller
} finally {
val duration = System.currentTimeMillis() - start
logger.info(
"{} {} {} - {}ms [{}]",
request.method,
request.requestURI,
response.status,
duration,
requestId
)
}
}
}

The same request-logging middleware in Express and Go:

app.use((req, res, next) => {
const start = Date.now();
const requestId = req.headers["x-request-id"] || crypto.randomUUID();
res.setHeader("X-Request-Id", requestId);
res.on("finish", () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.url} ${res.statusCode} - ${duration}ms`);
});
next();
});

Configure CORS centrally by implementing WebMvcConfigurer:

import cors from "cors";
app.use(cors({
origin: ["http://localhost:3000", "https://myapp.com"],
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
credentials: true
}));

Comparison: Express.js vs Go vs Spring Boot

Section titled “Comparison: Express.js vs Go vs Spring Boot”
FeatureExpress.js (TS)Go (chi/gin)Spring Boot (Kotlin)
Startup time~100ms~10ms~1-3s
Memory usage~30-80MB~10-30MB~100-300MB
Routingapp.get("/path", handler)r.Get("/path", handler)@GetMapping("/path")
JSON parsingBuilt-in / body-parserencoding/jsonJackson (auto)
Validationclass-validator / Zodgo-playground/validatorJakarta Bean Validation
DItsyringe / manualwire / manualBuilt-in IoC container
Configdotenv / convictenvconfig / Viperapplication.yml + type-safe
Health checksManual / terminusManualActuator (built-in)
Metricsprom-clientprometheus/client_golangMicrometer (built-in)
ORMPrisma / TypeORMGORM / sqlcSpring Data JPA / Exposed
TestingJest + Supertesttesting + httptestMockMvc / WebTestClient
Hot reloadnodemon / ts-node-devairSpring DevTools
Ecosystemnpm (huge)Go modules (growing)Maven Central (massive)
ConcurrencyEvent loop (single)GoroutinesVirtual threads / Coroutines
Learning curveLowLow-MediumMedium-High
Enterprise adoptionHigh (startups)High (infra/cloud)Very High (enterprise)
  • Enterprise environment with an existing Java/Spring ecosystem.
  • Complex business logic that benefits from mature DI, transactions, security.
  • Team with Java/Kotlin experience (or transitioning from Java).
  • Need for mature tooling: profiling, monitoring, database migration.
  • Microservices with Spring Cloud: service discovery, config server, circuit breakers.
  • Serverless / Lambda: Spring Boot’s startup time is too slow (consider Ktor or GraalVM native images).
  • Simple API / microservice: Spring might be overkill (Ktor or Go might be a better fit).
  • Tight memory constraints: Spring’s memory footprint is significant.
  • Rapid prototyping: Express or Go has faster time-to-first-endpoint.

Put it all together by building a complete REST API end to end — controllers, validation, global error handling, type-safe config, and an Actuator health check.