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.
What Is Spring Boot?
Section titled “What Is Spring Boot?”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.
Mental Model Mapping
Section titled “Mental Model Mapping”| Concept | Express.js (TS) | Go (net/http + chi) | Spring Boot (Kotlin) |
|---|---|---|---|
| Framework | Express / Fastify | net/http + chi/gin | Spring Boot |
| Server | Node.js runtime | Go runtime | Embedded Tomcat/Netty |
| Routing | app.get("/path", handler) | r.Get("/path", handler) | @GetMapping("/path") |
| Middleware | app.use(middleware) | r.Use(middleware) | Filters / Interceptors |
| DI container | tsyringe / InversifyJS | wire / manual | Built-in IoC container |
| Config | dotenv / config | envconfig / Viper | application.yml + @ConfigurationProperties |
| JSON | Built-in JSON.parse | encoding/json | Jackson (auto-configured) |
| Validation | class-validator / Joi / Zod | manual / go-playground/validator | Jakarta Bean Validation |
| Health checks | Custom or terminus | Custom handler | Actuator (built-in) |
Why Spring Boot for Kotlin?
Section titled “Why Spring Boot for Kotlin?”- 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.
Project Setup
Section titled “Project Setup”Using Spring Initializr
Section titled “Using Spring Initializr”The easiest way to start a Spring Boot project is start.spring.io:
- Project: Gradle - Kotlin
- Language: Kotlin
- Spring Boot: 3.4.x
- Java: 21
- Dependencies: Spring Web, Spring Boot DevTools, Spring Boot Actuator
Or use the command line:
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-apicd task-api./gradlew bootRunManual Setup
Section titled “Manual Setup”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:
| Plugin | Why |
|---|---|
kotlin("plugin.spring") | Makes @Component, @Service, etc. classes open (Spring uses class proxies) |
org.springframework.boot | Adds bootRun, bootJar tasks; manages Spring BOM |
io.spring.dependency-management | Auto-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.
rootProject.name = "task-api"package com.example.taskapi
import org.springframework.boot.autoconfigure.SpringBootApplicationimport org.springframework.boot.runApplication
@SpringBootApplicationclass 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”:
import express from "express";
const app = express();app.use(express.json()); // ≈ Jackson auto-configurationapp.listen(8080); // ≈ embedded Tomcatfunc main() { r := chi.NewRouter() r.Use(middleware.Logger) // ≈ Spring auto-configured logging http.ListenAndServe(":8080", r)}Project Directory Structure
Section titled “Project Directory Structure”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
Your First Controller
Section titled “Your First Controller”A @RestController is a class whose methods handle HTTP requests; Spring
auto-serializes whatever you return to JSON.
package com.example.taskapi.controller
import org.springframework.web.bind.annotation.GetMappingimport org.springframework.web.bind.annotation.RestController
@RestControllerclass 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!" });});r.Get("/hello", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Hello, World!"))})
r.Get("/hello/json", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"message": "Hello, World!"})})Run and test it:
./gradlew bootRuncurl http://localhost:8080/hello# Hello, World!
curl http://localhost:8080/hello/json# {"message":"Hello, World!"}Key Annotations
Section titled “Key Annotations”| Annotation | Purpose | Express Equivalent | Go Equivalent |
|---|---|---|---|
@RestController | Marks class as HTTP handler | The router file itself | The handler struct |
@GetMapping("/path") | Handle GET requests | app.get("/path", ...) | r.Get("/path", ...) |
@PostMapping("/path") | Handle POST requests | app.post("/path", ...) | r.Post("/path", ...) |
@PutMapping("/path") | Handle PUT requests | app.put("/path", ...) | r.Put("/path", ...) |
@DeleteMapping("/path") | Handle DELETE requests | app.delete("/path", ...) | r.Delete("/path", ...) |
@PatchMapping("/path") | Handle PATCH requests | app.patch("/path", ...) | r.Patch("/path", ...) |
@RequestMapping("/api") | Base path prefix | Router("/api") | r.Route("/api", ...) |
Full CRUD Controller
Section titled “Full CRUD Controller”A @RequestMapping("/api/tasks") on the class prefixes every route. The
taskService constructor parameter is injected by Spring — more on that below.
package com.example.taskapi.controller
import com.example.taskapi.dto.CreateTaskRequestimport com.example.taskapi.dto.UpdateTaskRequestimport com.example.taskapi.dto.TaskResponseimport com.example.taskapi.service.TaskServiceimport 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 // 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);r.Route("/api/tasks", func(r chi.Router) { r.Get("/", getAllTasks) r.Get("/{id}", getTask) r.Post("/", createTask) r.Put("/{id}", updateTask) r.Delete("/{id}", deleteTask) r.Get("/status/{status}", getTasksByStatus) r.Get("/search", searchTasks)})Key Differences:
- Spring automatically serializes return values to JSON (no
res.json()needed). @PathVariableextracts from the URL path (likereq.params.idorchi.URLParam).@RequestParamextracts query parameters (likereq.query.qorr.URL.Query().Get("q")).@RequestBodydeserializes the JSON body to a Kotlin class (likereq.bodyorjson.NewDecoder).ResponseEntity<T>gives you full control over status codes and headers.
Request and Response Handling
Section titled “Request and Response Handling”Path Variables
Section titled “Path Variables”@PathVariable binds a {segment} from the route to a function parameter. Spring
coerces types for you — note userId: Long below.
@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 = ...Query Parameters
Section titled “Query Parameters”@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@GetMappingfun 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")Request Body
Section titled “Request Body”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@PostMappingfun 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() middlewareapp.post("/api/tasks", (req, res) => { const body: CreateTaskRequest = req.body; // ...});type CreateTaskRequest struct { Title string `json:"title"` Description string `json:"description,omitempty"` Priority string `json:"priority,omitempty"`}
func createTask(w http.ResponseWriter, r *http.Request) { var req CreateTaskRequest json.NewDecoder(r.Body).Decode(&req) // ...}Request Headers
Section titled “Request Headers”@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")ResponseEntity for Full Control
Section titled “ResponseEntity for Full Control”When you need to set status codes, headers, or return no body, return a
ResponseEntity<T> instead of the raw value.
@PostMappingfun 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
Section titled “Dependency Injection”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:
flowchart LR
C["HTTP Client"] -->|"JSON request"| Ctrl["@RestController"]
Ctrl --> Svc["@Service"]
Svc --> Repo["@Repository"]
Repo --> DB[("Data store")]
Repo -->|"domain model"| Svc
Svc -->|"DTO"| Ctrl
Ctrl -->|"JSON response"| C
The Problem DI Solves
Section titled “The Problem DI Solves”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 manuallyclass 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@RestControllerclass TaskController( private val taskService: TaskService // Spring injects this)
@Serviceclass TaskService( private val taskRepository: TaskRepository // Spring injects this too)
@Repositoryclass TaskRepository // Spring creates a singleton of thisThe Stereotype Annotations
Section titled “The Stereotype Annotations”| Annotation | Purpose | When to Use |
|---|---|---|
@Component | Generic Spring-managed bean | General purpose |
@Service | Business logic layer | Services, use cases |
@Repository | Data access layer | Database operations |
@Controller / @RestController | HTTP layer | Request handlers |
@Configuration | Configuration/factory | Bean definitions, config |
These are all functionally the same (@Component under the hood). The different
names exist for clarity and convention.
Constructor Injection (Recommended)
Section titled “Constructor Injection (Recommended)”In Kotlin, constructor injection is the default — no @Autowired needed. Spring
reads the constructor, finds beans matching each parameter type, and passes them in.
@Serviceclass 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 ) {}}func NewTaskService(repo *TaskRepository, ns *NotificationService) *TaskService { return &TaskService{repo: repo, notificationService: ns}}
// wire.govar SuperSet = wire.NewSet(NewTaskService, NewTaskRepository, NewNotificationService)Defining Custom Beans
Section titled “Defining Custom Beans”When you need to configure a third-party class or create beans with custom logic,
declare @Bean factory methods in a @Configuration class:
@Configurationclass 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() }}Profiles and Conditional Beans
Section titled “Profiles and Conditional Beans”@Profile selects which bean is active per environment — for example, a local
database in dev and a pooled production database in prod:
@Configurationclass 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 } }}Interface-Based DI (For Testing)
Section titled “Interface-Based DI (For Testing)”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 interfaceinterface TaskRepository { fun findAll(): List<Task> fun findById(id: String): Task? fun save(task: Task): Task fun deleteById(id: String)}
// Production implementation@Repositoryclass 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@Serviceclass TaskService( private val repository: TaskRepository // Spring injects InMemoryTaskRepository) { fun findAll(): List<TaskResponse> = repository.findAll().map { it.toResponse() }}Configuration
Section titled “Configuration”application.yml
Section titled “application.yml”Spring Boot reads application.yml (or application.properties) from
src/main/resources/:
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 HTTPThe TypeScript and Go equivalents lean on environment variables:
PORT=8080APP_NAME="Task Management API"MAX_TASKS_PER_USER=100type Config struct { Port int `envconfig:"PORT" default:"8080"` AppName string `envconfig:"APP_NAME" default:"Task API"` MaxTasksPerUser int `envconfig:"MAX_TASKS_PER_USER" default:"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:
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 classesclass TaskApiApplicationThen inject it anywhere, fully typed:
@Serviceclass 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
Section titled “Profiles”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: 8080spring: profiles: active: dev # Default profile---# src/main/resources/application-dev.ymlapp: name: "Task API (DEV)"logging: level: com.example: DEBUG---# src/main/resources/application-prod.ymlserver: port: 9090app: name: "Task API"logging: level: com.example: WARNActivate a profile:
# Via environment variableSPRING_PROFILES_ACTIVE=prod ./gradlew bootRun
# Via command line./gradlew bootRun --args='--spring.profiles.active=prod'
# Via JVM property./gradlew bootRun -Dspring.profiles.active=prodThe TypeScript and Go equivalents flip an environment variable:
NODE_ENV=production node dist/index.jsAPP_ENV=production ./appReading Environment Variables
Section titled “Reading Environment Variables”Spring Boot automatically maps environment variables to config properties. The
pattern ${ENV_VAR:default} reads the environment variable or falls back to the default:
app: api-key: ${API_KEY:default-key} # ${ENV_VAR:default} db-url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/kotlin_course}Error Handling
Section titled “Error Handling”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:
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:
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(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 Expressapp.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" });});func errorMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(ErrorResponse{Error: "Internal Server Error"}) } }() next.ServeHTTP(w, r) })}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:
@RestControllerAdvicecatches exceptions from ALL controllers globally.
Validation
Section titled “Validation”Spring Boot integrates with Jakarta Bean Validation (formerly javax.validation),
included via spring-boot-starter-validation. You add constraint annotations to
your DTO fields.
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:
@PostMappingfun 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" }}Common Validation Annotations
Section titled “Common Validation Annotations”| Annotation | Purpose | TypeScript (class-validator) | Go (validator) |
|---|---|---|---|
@NotBlank | Not null/empty/whitespace | @IsNotEmpty() | validate:"required" |
@NotNull | Not null | @IsDefined() | validate:"required" |
@Size(min, max) | String/collection length | @Length(min, max) | validate:"min=1,max=200" |
@Min / @Max | Number range | @Min() / @Max() | validate:"min=0,max=100" |
@Email | Valid email format | @IsEmail() | validate:"email" |
@Pattern(regexp) | Regex match | @Matches(regex) | validate:"oneof=A B C" |
@Positive | Number > 0 | @IsPositive() | validate:"gt=0" |
Custom Validator
Section titled “Custom Validator”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 logicclass 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 itdata class CreateTaskRequest( @field:NotBlank @field:NoSwearWords val title: String)Jackson and Kotlin
Section titled “Jackson and Kotlin”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.
Customizing Jackson
Section titled “Customizing Jackson”Most tuning happens declaratively in 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 requestsOr programmatically with a customizer bean:
@Configurationclass 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) } }}Kotlin-Specific Jackson Tips
Section titled “Kotlin-Specific Jackson Tips”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)Actuator
Section titled “Actuator”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:
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 managementAvailable Endpoints
Section titled “Available Endpoints”# Health check -- is the app running?curl http://localhost:8080/actuator/health# {"status":"UP","components":{"diskSpace":{"status":"UP"},"ping":{"status":"UP"}}}
# App infocurl http://localhost:8080/actuator/info# {"app":{"name":"Task Management API","version":"1.0.0"}}
# All metricscurl http://localhost:8080/actuator/metrics# {"names":["jvm.memory.used","http.server.requests","process.uptime",...]}
# Specific metriccurl http://localhost:8080/actuator/metrics/jvm.memory.used# {"name":"jvm.memory.used","measurements":[{"statistic":"VALUE","value":1.234E8}]}
# Environment propertiescurl http://localhost:8080/actuator/envCustom Health Indicator
Section titled “Custom Health Indicator”Implement HealthIndicator to plug your own checks into /actuator/health:
@Componentclass 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/terminusconst healthChecks = { "/healthcheck": async () => { const count = await taskRepository.count(); return { taskCount: count }; }};createTerminus(server, { healthChecks });http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { count, err := repo.Count() if err != nil { w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(map[string]string{"status": "DOWN"}) return } json.NewEncoder(w).Encode(map[string]interface{}{ "status": "UP", "taskCount": count, })})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.
Testing Spring Boot
Section titled “Testing Spring Boot”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:
package com.example.taskapi.service
import com.example.taskapi.dto.CreateTaskRequestimport com.example.taskapi.exception.TaskNotFoundExceptionimport com.example.taskapi.repository.InMemoryTaskRepositoryimport org.junit.jupiter.api.BeforeEachimport org.junit.jupiter.api.Testimport org.junit.jupiter.api.assertThrowsimport kotlin.test.assertEqualsimport 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:
package com.example.taskapi.controller
import org.junit.jupiter.api.Testimport org.springframework.beans.factory.annotation.Autowiredimport org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvcimport org.springframework.boot.test.context.SpringBootTestimport org.springframework.http.MediaTypeimport org.springframework.test.web.servlet.*
@SpringBootTest@AutoConfigureMockMvcclass 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"); });});func TestCreateTask(t *testing.T) { body := `{"title": "Test task"}` req := httptest.NewRequest("POST", "/api/tasks", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
assert.Equal(t, 201, rr.Code)}Middleware: Filters and Interceptors
Section titled “Middleware: Filters and Interceptors”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 orderclass 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();});func LoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("%s %s %dms", r.Method, r.URL.Path, time.Since(start).Milliseconds()) })}r.Use(LoggingMiddleware)CORS Configuration
Section titled “CORS Configuration”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}));@Configurationclass CorsConfig : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:3000", "https://myapp.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600) }}Comparison: Express.js vs Go vs Spring Boot
Section titled “Comparison: Express.js vs Go vs Spring Boot”| Feature | Express.js (TS) | Go (chi/gin) | Spring Boot (Kotlin) |
|---|---|---|---|
| Startup time | ~100ms | ~10ms | ~1-3s |
| Memory usage | ~30-80MB | ~10-30MB | ~100-300MB |
| Routing | app.get("/path", handler) | r.Get("/path", handler) | @GetMapping("/path") |
| JSON parsing | Built-in / body-parser | encoding/json | Jackson (auto) |
| Validation | class-validator / Zod | go-playground/validator | Jakarta Bean Validation |
| DI | tsyringe / manual | wire / manual | Built-in IoC container |
| Config | dotenv / convict | envconfig / Viper | application.yml + type-safe |
| Health checks | Manual / terminus | Manual | Actuator (built-in) |
| Metrics | prom-client | prometheus/client_golang | Micrometer (built-in) |
| ORM | Prisma / TypeORM | GORM / sqlc | Spring Data JPA / Exposed |
| Testing | Jest + Supertest | testing + httptest | MockMvc / WebTestClient |
| Hot reload | nodemon / ts-node-dev | air | Spring DevTools |
| Ecosystem | npm (huge) | Go modules (growing) | Maven Central (massive) |
| Concurrency | Event loop (single) | Goroutines | Virtual threads / Coroutines |
| Learning curve | Low | Low-Medium | Medium-High |
| Enterprise adoption | High (startups) | High (infra/cloud) | Very High (enterprise) |
When to Choose Spring Boot
Section titled “When to Choose Spring Boot”- 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.
When to Choose Something Else
Section titled “When to Choose Something Else”- 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.
Practice
Section titled “Practice”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.