Write a Spring Boot Application with Kotlin (Part 2)

Kotlin SpringBoot REST Architecture

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:

  1. REST Controllers with Kotlin Best Practices - Clean separation of HTTP concerns from business logic
  2. Spring Boot Actuator - Making your service production-ready (with security considerations)
  3. 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:

  1. Data Classes: All inputs/outputs are Kotlin data classes, providing immutability and clean separation
  2. Companion Objects: Used for mapping between domain and DTOs
  3. Nullable Types: Proper use of nullable types (String?) for optional fields
  4. Extension Functions: Could be used for mapping (we’ll see this in the service layer)
  5. HTTP Status Codes: Explicit status codes using ResponseEntity (201 for creation, 204 for deletion)
  6. Path Variables: Proper use of @PathVariable with 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:

  1. Information Disclosure: Actuator endpoints can reveal sensitive information about your application
  2. Metrics Exposure: Could expose internal system metrics and performance data
  3. Health Check Abuse: Attackers could use health checks to understand your system architecture
  4. 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

  1. Testability: Business logic can be tested without Spring Boot context
  2. Framework Independence: Core logic works with any framework or even no framework
  3. Maintainability: Clear boundaries between concerns
  4. 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:

  1. Clean REST API with proper HTTP status codes and Kotlin data classes
  2. Production monitoring with Spring Boot Actuator (and security considerations)
  3. 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! 🚀