Write a Spring Boot Application with Kotlin (Part 2)
Note: This blog post was reviewed using AI for factual correctness and clarity. All content was tested in my private homelab to ensure accuracy.
Welcome back! In Part 1, we set up a basic Spring Boot application with Kotlin. Now it’s time to make it actually useful by adding REST endpoints, following Kotlin best practices, and making it production-ready.
In this part, we’ll focus on three key areas:
- REST Controllers with Kotlin Best Practices - Clean separation of HTTP concerns from business logic
- Spring Boot Actuator - Making your service production-ready (with security considerations)
- Architecture Principles - Why and how to separate business logic from framework concerns
🎯 1. REST Controllers with Kotlin Best Practices
Let’s start by creating a proper REST API. We’ll build a simple task management system to demonstrate the concepts.
Data Classes for Clean Separation
First, let’s create our domain models using Kotlin data classes:
// src/main/kotlin/com/example/domain/Task.kt
package com.example.domain
import java.time.LocalDateTime
data class Task(
val id: String,
val title: String,
val description: String?,
val completed: Boolean = false,
val createdAt: LocalDateTime = LocalDateTime.now(),
val updatedAt: LocalDateTime = LocalDateTime.now()
)
Now let’s create DTOs (Data Transfer Objects) to separate our HTTP layer from our domain:
// src/main/kotlin/com/example/dto/TaskDto.kt
package com.example.dto
import com.example.domain.Task
import java.time.LocalDateTime
// Request DTOs
data class CreateTaskRequest(
val title: String,
val description: String?
)
data class UpdateTaskRequest(
val title: String? = null,
val description: String? = null,
val completed: Boolean? = null
)
// Response DTOs
data class TaskResponse(
val id: String,
val title: String,
val description: String?,
val completed: Boolean,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime
) {
companion object {
fun fromDomain(task: Task): TaskResponse = TaskResponse(
id = task.id,
title = task.title,
description = task.description,
completed = task.completed,
createdAt = task.createdAt,
updatedAt = task.updatedAt
)
}
}
data class TaskListResponse(
val tasks: List<TaskResponse>,
val total: Int
)
The REST Controller
Now let’s create a controller that follows Kotlin and Spring Boot best practices:
// src/main/kotlin/com/example/controller/TaskController.kt
package com.example.controller
import com.example.dto.*
import com.example.service.TaskService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.util.*
@RestController
@RequestMapping("/api/v1/tasks")
class TaskController(
private val taskService: TaskService
) {
@GetMapping
fun getAllTasks(): TaskListResponse {
val tasks = taskService.getAllTasks()
return TaskListResponse(
tasks = tasks.map { TaskResponse.fromDomain(it) },
total = tasks.size
)
}
@GetMapping("/{id}")
fun getTaskById(@PathVariable id: String): TaskResponse? {
return taskService.getTaskById(id)?.let { TaskResponse.fromDomain(it) }
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createTask(@RequestBody request: CreateTaskRequest): TaskResponse {
val task = taskService.createTask(request.title, request.description)
return TaskResponse.fromDomain(task)
}
@PutMapping("/{id}")
fun updateTask(
@PathVariable id: String,
@RequestBody request: UpdateTaskRequest
): TaskResponse? {
return taskService.updateTask(id, request)?.let { TaskResponse.fromDomain(it) }
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteTask(@PathVariable id: String) {
if (!taskService.deleteTask(id)) {
throw IllegalArgumentException("Task not found")
}
}
}
Key Kotlin Best Practices Applied:
- Data Classes: All inputs/outputs are Kotlin data classes, providing immutability and clean separation
- Companion Objects: Used for mapping between domain and DTOs
- Nullable Types: Proper use of nullable types (
String?) for optional fields - Extension Functions: Could be used for mapping (we’ll see this in the service layer)
- HTTP Status Codes: Explicit status codes using
ResponseEntity(201 for creation, 204 for deletion) - Path Variables: Proper use of
@PathVariablewith type safety
🔧 2. Spring Boot Actuator for Production Readiness
Spring Boot Actuator provides production-ready features like health checks, metrics, and monitoring endpoints.
Adding Actuator Dependencies
Update your build.gradle.kts:
dependencies {
// ... existing dependencies ...
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
}
Configuration
Create src/main/resources/application.yml:
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
base-path: /actuator
endpoint:
health:
show-details: when-authorized
metrics:
enabled: true
metrics:
export:
prometheus:
enabled: true
# Custom application info
info:
app:
name: Task Management API
version: 1.0.0
description: A Spring Boot Kotlin application for managing tasks
Custom Health Indicator
Let’s create a custom health check for our task service:
// src/main/kotlin/com/example/actuator/TaskServiceHealthIndicator.kt
package com.example.actuator
import com.example.service.TaskService
import org.springframework.boot.actuator.health.Health
import org.springframework.boot.actuator.health.HealthIndicator
import org.springframework.stereotype.Component
@Component
class TaskServiceHealthIndicator(
private val taskService: TaskService
) : HealthIndicator {
override fun health(): Health {
return try {
// Simple health check - try to get all tasks
taskService.getAllTasks()
Health.up()
.withDetail("service", "TaskService")
.withDetail("status", "Available")
.build()
} catch (e: Exception) {
Health.down()
.withDetail("service", "TaskService")
.withDetail("error", e.message)
.build()
}
}
}
⚠️ Security Concerns and Why Not to Expose Directly
Never expose Actuator endpoints directly to the internet! Here’s why:
- Information Disclosure: Actuator endpoints can reveal sensitive information about your application
- Metrics Exposure: Could expose internal system metrics and performance data
- Health Check Abuse: Attackers could use health checks to understand your system architecture
- Management Endpoints: Some endpoints could allow remote management of your application
Security Configuration
Add this to your application.yml:
# Production security settings
management:
endpoints:
web:
exposure:
include: health,info # Only expose what's necessary
base-path: /internal/actuator # Use a non-standard path
endpoint:
health:
show-details: never # Never show details in production
For production, consider:
- Using Spring Security to protect actuator endpoints
- Running behind a reverse proxy (nginx, API Gateway)
- Using network-level security (firewalls, VPCs)
- Implementing proper authentication and authorization
🏗️ 3. Separation of Concerns: Business Logic vs Framework
This is where the magic happens! Let’s demonstrate why and how to separate business logic from Spring Boot concerns.
The Service Layer (Pure Kotlin Business Logic)
// src/main/kotlin/com/example/service/TaskService.kt
package com.example.service
import com.example.domain.Task
import com.example.dto.UpdateTaskRequest
import java.time.LocalDateTime
import java.util.*
class TaskService {
// In-memory storage for demo purposes
private val tasks = mutableMapOf<String, Task>()
fun getAllTasks(): List<Task> = tasks.values.toList()
fun getTaskById(id: String): Task? = tasks[id]
fun createTask(title: String, description: String?): Task {
val task = Task(
id = UUID.randomUUID().toString(),
title = title,
description = description,
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now()
)
tasks[task.id] = task
return task
}
fun updateTask(id: String, request: UpdateTaskRequest): Task? {
val existingTask = tasks[id] ?: return null
val updatedTask = existingTask.copy(
title = request.title ?: existingTask.title,
description = request.description ?: existingTask.description,
completed = request.completed ?: existingTask.completed,
updatedAt = LocalDateTime.now()
)
tasks[id] = updatedTask
return updatedTask
}
fun deleteTask(id: String): Boolean = tasks.remove(id) != null
}
Domain Logic (Pure Kotlin)
// src/main/kotlin/com/example/domain/TaskValidation.kt
package com.example.domain
object TaskValidation {
fun validateTitle(title: String): ValidationResult {
return when {
title.isBlank() -> ValidationResult.Error("Title cannot be blank")
title.length > 100 -> ValidationResult.Error("Title cannot exceed 100 characters")
else -> ValidationResult.Success
}
}
fun validateDescription(description: String?): ValidationResult {
return when {
description?.length ?: 0 > 500 -> ValidationResult.Error("Description cannot exceed 500 characters")
else -> ValidationResult.Success
}
}
}
sealed class ValidationResult {
object Success : ValidationResult()
data class Error(val message: String) : ValidationResult()
}
Enhanced Service with Validation
// src/main/kotlin/com/example/service/TaskService.kt (updated)
package com.example.service
import com.example.domain.Task
import com.example.domain.TaskValidation
import com.example.domain.ValidationResult
import com.example.dto.UpdateTaskRequest
import java.time.LocalDateTime
import java.util.*
class TaskService {
private val tasks = mutableMapOf<String, Task>()
fun getAllTasks(): List<Task> = tasks.values.toList()
fun getTaskById(id: String): Task? = tasks[id]
fun createTask(title: String, description: String?): Task {
// Business logic validation
when (val titleValidation = TaskValidation.validateTitle(title)) {
is ValidationResult.Error -> throw IllegalArgumentException(titleValidation.message)
is ValidationResult.Success -> { /* continue */ }
}
when (val descValidation = TaskValidation.validateDescription(description)) {
is ValidationResult.Error -> throw IllegalArgumentException(descValidation.message)
is ValidationResult.Success -> { /* continue */ }
}
val task = Task(
id = UUID.randomUUID().toString(),
title = title,
description = description,
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now()
)
tasks[task.id] = task
return task
}
fun updateTask(id: String, request: UpdateTaskRequest): Task? {
val existingTask = tasks[id] ?: return null
// Validate new values if provided
request.title?.let { title ->
when (val validation = TaskValidation.validateTitle(title)) {
is ValidationResult.Error -> throw IllegalArgumentException(validation.message)
is ValidationResult.Success -> { /* continue */ }
}
}
request.description?.let { desc ->
when (val validation = TaskValidation.validateDescription(desc)) {
is ValidationResult.Error -> throw IllegalArgumentException(validation.message)
is ValidationResult.Success -> { /* continue */ }
}
}
val updatedTask = existingTask.copy(
title = request.title ?: existingTask.title,
description = request.description ?: existingTask.description,
completed = request.completed ?: existingTask.completed,
updatedAt = LocalDateTime.now()
)
tasks[id] = updatedTask
return updatedTask
}
fun deleteTask(id: String): Boolean = tasks.remove(id) != null
}
Exception Handling (Framework Concern)
// src/main/kotlin/com/example/controller/GlobalExceptionHandler.kt
package com.example.controller
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
@ControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException::class)
fun handleIllegalArgumentException(ex: IllegalArgumentException): ResponseEntity<ErrorResponse> {
val errorResponse = ErrorResponse(
error = "Validation Error",
message = ex.message ?: "Invalid input",
status = HttpStatus.BAD_REQUEST.value()
)
return ResponseEntity.badRequest().body(errorResponse)
}
@ExceptionHandler(Exception::class)
fun handleGenericException(ex: Exception): ResponseEntity<ErrorResponse> {
val errorResponse = ErrorResponse(
error = "Internal Server Error",
message = "An unexpected error occurred",
status = HttpStatus.INTERNAL_SERVER_ERROR.value()
)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse)
}
}
data class ErrorResponse(
val error: String,
val message: String,
val status: Int
)
Why This Separation Matters
- Testability: Business logic can be tested without Spring Boot context
- Framework Independence: Core logic works with any framework or even no framework
- Maintainability: Clear boundaries between concerns
- Reusability: Business logic can be reused across different interfaces (REST, GraphQL, CLI, etc.)
Testing the Business Logic
// src/test/kotlin/com/example/service/TaskServiceTest.kt
package com.example.service
import com.example.domain.TaskValidation
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class TaskServiceTest {
@Test
fun `should create task with valid data`() {
val service = TaskService()
val task = service.createTask("Test Task", "Test Description")
assertNotNull(task)
assertEquals("Test Task", task.title)
assertEquals("Test Description", task.description)
assertEquals(false, task.completed)
}
@Test
fun `should throw exception for invalid title`() {
val service = TaskService()
assertThrows<IllegalArgumentException> {
service.createTask("", "Valid description")
}
}
@Test
fun `should validate title length`() {
val longTitle = "a".repeat(101)
val result = TaskValidation.validateTitle(longTitle)
assert(result is TaskValidation.ValidationResult.Error)
assertEquals("Title cannot exceed 100 characters", (result as TaskValidation.ValidationResult.Error).message)
}
}
🎉 What We’ve Accomplished
In this part, we’ve built a production-ready Spring Boot application with Kotlin that demonstrates:
- Clean REST API with proper HTTP status codes and Kotlin data classes
- Production monitoring with Spring Boot Actuator (and security considerations)
- Separation of concerns where business logic is pure Kotlin, independent of Spring Boot
The key takeaway is that Spring Boot should handle the “boring” infrastructure details (HTTP, JSON serialization, dependency injection) while your business logic remains pure, testable, and framework-agnostic.
In the next part, we could explore:
- Database integration with Spring Data JPA
- Caching strategies
- API documentation with OpenAPI/Swagger
- Advanced Kotlin features like coroutines for async operations
Until then, happy coding with Kotlin and Spring Boot! 🚀