Kotlin - Spring Boot

Overview

Spring Boot with Kotlin provides a powerful combination for building modern web applications. This tutorial covers Spring Boot setup with Kotlin, configuration, REST API development, dependency injection, data access, and production-ready features.

🎯 Learning Objectives:
  • Understand Spring Boot configuration with Kotlin
  • Learn to build REST APIs using Spring MVC
  • Master dependency injection and component management
  • Implement data access with Spring Data JPA
  • Apply testing strategies for Spring Boot applications

Getting Started with Spring Boot and Kotlin

Project Setup

<!-- pom.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>kotlin-spring-boot</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <properties>
        <kotlin.version>1.9.10</kotlin.version>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Starters -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- Kotlin -->
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-reflect</artifactId>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.module</groupId>
            <artifactId>jackson-module-kotlin</artifactId>
        </dependency>

        <!-- Database -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Testing -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

Main Application Class

// Application.kt
package com.example.kotlinspringboot

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class KotlinSpringBootApplication

fun main(args: Array<String>) {
    runApplication<KotlinSpringBootApplication>(*args)
}

Configuration with Kotlin

Application Properties

# application.yml
server:
  port: 8080

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password: 
  
  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  
  h2:
    console:
      enabled: true
      path: /h2-console

logging:
  level:
    com.example: DEBUG
    org.springframework.web: DEBUG

Configuration Classes

// DatabaseConfig.kt
package com.example.kotlinspringboot.config

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Configuration

@ConfigurationProperties(prefix = "app.database")
data class DatabaseProperties(
    var maxConnections: Int = 10,
    var timeout: Long = 30000,
    var retryAttempts: Int = 3
)

@Configuration
@EnableConfigurationProperties(DatabaseProperties::class)
class DatabaseConfig(private val databaseProperties: DatabaseProperties) {
    
    fun printConfig() {
        println("Database config: $databaseProperties")
    }
}

// WebConfig.kt
package com.example.kotlinspringboot.config

import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebConfig : WebMvcConfigurer {
    
    override fun addCorsMappings(registry: CorsRegistry) {
        registry.addMapping("/api/**")
            .allowedOrigins("http://localhost:3000", "http://localhost:8081")
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true)
    }
}

Data Models and JPA

Entity Classes

// User.kt
package com.example.kotlinspringboot.entity

import jakarta.persistence.*
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import java.time.LocalDateTime

@Entity
@Table(name = "users")
data class User(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    
    @field:NotBlank(message = "Name is required")
    @field:Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    @Column(nullable = false)
    val name: String,
    
    @field:Email(message = "Email should be valid")
    @field:NotBlank(message = "Email is required")
    @Column(nullable = false, unique = true)
    val email: String,
    
    @Column(name = "created_at")
    val createdAt: LocalDateTime = LocalDateTime.now(),
    
    @Column(name = "updated_at")
    var updatedAt: LocalDateTime = LocalDateTime.now(),
    
    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
    val posts: List<Post> = emptyList()
) {
    // JPA requires no-arg constructor
    constructor() : this(0, "", "", LocalDateTime.now(), LocalDateTime.now(), emptyList())
}

// Post.kt
package com.example.kotlinspringboot.entity

import jakarta.persistence.*
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import java.time.LocalDateTime

@Entity
@Table(name = "posts")
data class Post(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    
    @field:NotBlank(message = "Title is required")
    @field:Size(max = 200, message = "Title must not exceed 200 characters")
    @Column(nullable = false)
    val title: String,
    
    @field:NotBlank(message = "Content is required")
    @Column(nullable = false, columnDefinition = "TEXT")
    val content: String,
    
    @Column(name = "created_at")
    val createdAt: LocalDateTime = LocalDateTime.now(),
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    val user: User
) {
    constructor() : this(0, "", "", LocalDateTime.now(), User())
}

Repository Layer

// UserRepository.kt
package com.example.kotlinspringboot.repository

import com.example.kotlinspringboot.entity.User
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository

@Repository
interface UserRepository : JpaRepository<User, Long> {
    
    fun findByEmail(email: String): User?
    
    fun findByNameContainingIgnoreCase(name: String): List<User>
    
    fun existsByEmail(email: String): Boolean
    
    @Query("SELECT u FROM User u WHERE u.createdAt >= :date")
    fun findUsersCreatedAfter(@Param("date") date: java.time.LocalDateTime): List<User>
    
    @Query("SELECT u FROM User u LEFT JOIN FETCH u.posts WHERE u.id = :id")
    fun findByIdWithPosts(@Param("id") id: Long): User?
    
    // Custom query with pagination
    @Query("SELECT u FROM User u WHERE u.name LIKE %:name%")
    fun findByNameContaining(@Param("name") name: String, pageable: Pageable): Page<User>
}

// PostRepository.kt
package com.example.kotlinspringboot.repository

import com.example.kotlinspringboot.entity.Post
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository

@Repository
interface PostRepository : JpaRepository<Post, Long> {
    
    fun findByUserId(userId: Long): List<Post>
    
    fun findByTitleContainingIgnoreCase(title: String, pageable: Pageable): Page<Post>
    
    @Query("SELECT p FROM Post p WHERE p.user.id = :userId ORDER BY p.createdAt DESC")
    fun findLatestPostsByUser(@Param("userId") userId: Long, pageable: Pageable): Page<Post>
}

Service Layer

Business Logic Services

// UserService.kt
package com.example.kotlinspringboot.service

import com.example.kotlinspringboot.dto.CreateUserRequest
import com.example.kotlinspringboot.dto.UpdateUserRequest
import com.example.kotlinspringboot.dto.UserResponse
import com.example.kotlinspringboot.entity.User
import com.example.kotlinspringboot.exception.ResourceNotFoundException
import com.example.kotlinspringboot.exception.DuplicateResourceException
import com.example.kotlinspringboot.repository.UserRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime

@Service
@Transactional
class UserService(private val userRepository: UserRepository) {
    
    fun createUser(request: CreateUserRequest): UserResponse {
        if (userRepository.existsByEmail(request.email)) {
            throw DuplicateResourceException("User with email ${request.email} already exists")
        }
        
        val user = User(
            name = request.name,
            email = request.email
        )
        
        val savedUser = userRepository.save(user)
        return mapToResponse(savedUser)
    }
    
    @Transactional(readOnly = true)
    fun getUserById(id: Long): UserResponse {
        val user = userRepository.findById(id)
            .orElseThrow { ResourceNotFoundException("User not found with id: $id") }
        return mapToResponse(user)
    }
    
    @Transactional(readOnly = true)
    fun getUserByEmail(email: String): UserResponse? {
        return userRepository.findByEmail(email)?.let { mapToResponse(it) }
    }
    
    @Transactional(readOnly = true)
    fun getAllUsers(pageable: Pageable): Page<UserResponse> {
        return userRepository.findAll(pageable).map { mapToResponse(it) }
    }
    
    @Transactional(readOnly = true)
    fun searchUsers(name: String, pageable: Pageable): Page<UserResponse> {
        return userRepository.findByNameContaining(name, pageable).map { mapToResponse(it) }
    }
    
    fun updateUser(id: Long, request: UpdateUserRequest): UserResponse {
        val existingUser = userRepository.findById(id)
            .orElseThrow { ResourceNotFoundException("User not found with id: $id") }
        
        // Check if email is being changed and if it already exists
        if (request.email != existingUser.email && userRepository.existsByEmail(request.email)) {
            throw DuplicateResourceException("User with email ${request.email} already exists")
        }
        
        val updatedUser = existingUser.copy(
            name = request.name,
            email = request.email,
            updatedAt = LocalDateTime.now()
        )
        
        val savedUser = userRepository.save(updatedUser)
        return mapToResponse(savedUser)
    }
    
    fun deleteUser(id: Long) {
        if (!userRepository.existsById(id)) {
            throw ResourceNotFoundException("User not found with id: $id")
        }
        userRepository.deleteById(id)
    }
    
    @Transactional(readOnly = true)
    fun getUserWithPosts(id: Long): UserResponse {
        val user = userRepository.findByIdWithPosts(id)
            ?: throw ResourceNotFoundException("User not found with id: $id")
        return mapToResponse(user)
    }
    
    private fun mapToResponse(user: User): UserResponse {
        return UserResponse(
            id = user.id,
            name = user.name,
            email = user.email,
            createdAt = user.createdAt,
            updatedAt = user.updatedAt,
            postCount = user.posts.size
        )
    }
}

DTOs and Validation

Data Transfer Objects

// DTOs.kt
package com.example.kotlinspringboot.dto

import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import java.time.LocalDateTime

data class CreateUserRequest(
    @field:NotBlank(message = "Name is required")
    @field:Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    val name: String,
    
    @field:Email(message = "Email should be valid")
    @field:NotBlank(message = "Email is required")
    val email: String
)

data class UpdateUserRequest(
    @field:NotBlank(message = "Name is required")
    @field:Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    val name: String,
    
    @field:Email(message = "Email should be valid")
    @field:NotBlank(message = "Email is required")
    val email: String
)

data class UserResponse(
    val id: Long,
    val name: String,
    val email: String,
    val createdAt: LocalDateTime,
    val updatedAt: LocalDateTime,
    val postCount: Int
)

data class CreatePostRequest(
    @field:NotBlank(message = "Title is required")
    @field:Size(max = 200, message = "Title must not exceed 200 characters")
    val title: String,
    
    @field:NotBlank(message = "Content is required")
    val content: String,
    
    val userId: Long
)

data class PostResponse(
    val id: Long,
    val title: String,
    val content: String,
    val createdAt: LocalDateTime,
    val author: String
)

REST Controllers

API Controllers

// UserController.kt
package com.example.kotlinspringboot.controller

import com.example.kotlinspringboot.dto.CreateUserRequest
import com.example.kotlinspringboot.dto.UpdateUserRequest
import com.example.kotlinspringboot.dto.UserResponse
import com.example.kotlinspringboot.service.UserService
import jakarta.validation.Valid
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = ["http://localhost:3000"])
class UserController(private val userService: UserService) {
    
    @PostMapping
    fun createUser(@Valid @RequestBody request: CreateUserRequest): ResponseEntity<UserResponse> {
        val user = userService.createUser(request)
        return ResponseEntity.status(HttpStatus.CREATED).body(user)
    }
    
    @GetMapping("/{id}")
    fun getUserById(@PathVariable id: Long): ResponseEntity<UserResponse> {
        val user = userService.getUserById(id)
        return ResponseEntity.ok(user)
    }
    
    @GetMapping
    fun getAllUsers(
        @RequestParam(defaultValue = "0") page: Int,
        @RequestParam(defaultValue = "10") size: Int,
        @RequestParam(defaultValue = "id") sortBy: String,
        @RequestParam(defaultValue = "asc") sortDir: String,
        @RequestParam(required = false) search: String?
    ): ResponseEntity<Page<UserResponse>> {
        
        val direction = if (sortDir.equals("desc", ignoreCase = true)) {
            Sort.Direction.DESC
        } else {
            Sort.Direction.ASC
        }
        
        val pageable = PageRequest.of(page, size, Sort.by(direction, sortBy))
        
        val users = if (search.isNullOrBlank()) {
            userService.getAllUsers(pageable)
        } else {
            userService.searchUsers(search, pageable)
        }
        
        return ResponseEntity.ok(users)
    }
    
    @PutMapping("/{id}")
    fun updateUser(
        @PathVariable id: Long,
        @Valid @RequestBody request: UpdateUserRequest
    ): ResponseEntity<UserResponse> {
        val user = userService.updateUser(id, request)
        return ResponseEntity.ok(user)
    }
    
    @DeleteMapping("/{id}")
    fun deleteUser(@PathVariable id: Long): ResponseEntity<Void> {
        userService.deleteUser(id)
        return ResponseEntity.noContent().build()
    }
    
    @GetMapping("/{id}/with-posts")
    fun getUserWithPosts(@PathVariable id: Long): ResponseEntity<UserResponse> {
        val user = userService.getUserWithPosts(id)
        return ResponseEntity.ok(user)
    }
    
    @GetMapping("/by-email")
    fun getUserByEmail(@RequestParam email: String): ResponseEntity<UserResponse> {
        val user = userService.getUserByEmail(email)
        return if (user != null) {
            ResponseEntity.ok(user)
        } else {
            ResponseEntity.notFound().build()
        }
    }
}

Exception Handling

Global Exception Handler

// GlobalExceptionHandler.kt
package com.example.kotlinspringboot.exception

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.context.request.WebRequest
import java.time.LocalDateTime

@RestControllerAdvice
class GlobalExceptionHandler {
    
    @ExceptionHandler(ResourceNotFoundException::class)
    fun handleResourceNotFoundException(
        ex: ResourceNotFoundException,
        request: WebRequest
    ): ResponseEntity<ErrorResponse> {
        val error = ErrorResponse(
            timestamp = LocalDateTime.now(),
            status = HttpStatus.NOT_FOUND.value(),
            error = "Not Found",
            message = ex.message ?: "Resource not found",
            path = request.getDescription(false).removePrefix("uri=")
        )
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error)
    }
    
    @ExceptionHandler(DuplicateResourceException::class)
    fun handleDuplicateResourceException(
        ex: DuplicateResourceException,
        request: WebRequest
    ): ResponseEntity<ErrorResponse> {
        val error = ErrorResponse(
            timestamp = LocalDateTime.now(),
            status = HttpStatus.CONFLICT.value(),
            error = "Conflict",
            message = ex.message ?: "Resource already exists",
            path = request.getDescription(false).removePrefix("uri=")
        )
        return ResponseEntity.status(HttpStatus.CONFLICT).body(error)
    }
    
    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidationExceptions(
        ex: MethodArgumentNotValidException,
        request: WebRequest
    ): ResponseEntity<ValidationErrorResponse> {
        val errors = mutableMapOf<String, String>()
        ex.bindingResult.allErrors.forEach { error ->
            val fieldName = (error as FieldError).field
            val errorMessage = error.getDefaultMessage() ?: "Invalid value"
            errors[fieldName] = errorMessage
        }
        
        val error = ValidationErrorResponse(
            timestamp = LocalDateTime.now(),
            status = HttpStatus.BAD_REQUEST.value(),
            error = "Validation Failed",
            message = "Invalid input parameters",
            path = request.getDescription(false).removePrefix("uri="),
            validationErrors = errors
        )
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error)
    }
    
    @ExceptionHandler(Exception::class)
    fun handleGenericException(
        ex: Exception,
        request: WebRequest
    ): ResponseEntity<ErrorResponse> {
        val error = ErrorResponse(
            timestamp = LocalDateTime.now(),
            status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
            error = "Internal Server Error",
            message = "An unexpected error occurred",
            path = request.getDescription(false).removePrefix("uri=")
        )
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error)
    }
}

// Custom exceptions
class ResourceNotFoundException(message: String) : RuntimeException(message)
class DuplicateResourceException(message: String) : RuntimeException(message)

// Error response DTOs
data class ErrorResponse(
    val timestamp: LocalDateTime,
    val status: Int,
    val error: String,
    val message: String,
    val path: String
)

data class ValidationErrorResponse(
    val timestamp: LocalDateTime,
    val status: Int,
    val error: String,
    val message: String,
    val path: String,
    val validationErrors: Map<String, String>
)

Testing

Unit and Integration Tests

// UserServiceTest.kt
package com.example.kotlinspringboot.service

import com.example.kotlinspringboot.dto.CreateUserRequest
import com.example.kotlinspringboot.entity.User
import com.example.kotlinspringboot.exception.DuplicateResourceException
import com.example.kotlinspringboot.exception.ResourceNotFoundException
import com.example.kotlinspringboot.repository.UserRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import java.time.LocalDateTime
import java.util.*

class UserServiceTest {
    
    private lateinit var userRepository: UserRepository
    private lateinit var userService: UserService
    
    @BeforeEach
    fun setUp() {
        userRepository = mockk()
        userService = UserService(userRepository)
    }
    
    @Test
    fun `should create user successfully`() {
        // Given
        val request = CreateUserRequest("John Doe", "[email protected]")
        val savedUser = User(1L, "John Doe", "[email protected]")
        
        every { userRepository.existsByEmail("[email protected]") } returns false
        every { userRepository.save(any()) } returns savedUser
        
        // When
        val result = userService.createUser(request)
        
        // Then
        assertEquals("John Doe", result.name)
        assertEquals("[email protected]", result.email)
        verify { userRepository.save(any()) }
    }
    
    @Test
    fun `should throw exception when creating user with existing email`() {
        // Given
        val request = CreateUserRequest("John Doe", "[email protected]")
        every { userRepository.existsByEmail("[email protected]") } returns true
        
        // When & Then
        assertThrows<DuplicateResourceException> {
            userService.createUser(request)
        }
    }
    
    @Test
    fun `should get user by id successfully`() {
        // Given
        val userId = 1L
        val user = User(userId, "John Doe", "[email protected]")
        every { userRepository.findById(userId) } returns Optional.of(user)
        
        // When
        val result = userService.getUserById(userId)
        
        // Then
        assertEquals(userId, result.id)
        assertEquals("John Doe", result.name)
    }
    
    @Test
    fun `should throw exception when user not found`() {
        // Given
        val userId = 1L
        every { userRepository.findById(userId) } returns Optional.empty()
        
        // When & Then
        assertThrows<ResourceNotFoundException> {
            userService.getUserById(userId)
        }
    }
}

// UserControllerTest.kt
package com.example.kotlinspringboot.controller

import com.example.kotlinspringboot.dto.CreateUserRequest
import com.example.kotlinspringboot.dto.UserResponse
import com.example.kotlinspringboot.service.UserService
import com.fasterxml.jackson.databind.ObjectMapper
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import java.time.LocalDateTime

@WebMvcTest(UserController::class)
class UserControllerTest {
    
    @Autowired
    private lateinit var mockMvc: MockMvc
    
    @Autowired
    private lateinit var objectMapper: ObjectMapper
    
    @Autowired
    private lateinit var userService: UserService
    
    @TestConfiguration
    class TestConfig {
        @Bean
        fun userService(): UserService = mockk()
    }
    
    @Test
    fun `should create user successfully`() {
        // Given
        val request = CreateUserRequest("John Doe", "[email protected]")
        val response = UserResponse(1L, "John Doe", "[email protected]", 
            LocalDateTime.now(), LocalDateTime.now(), 0)
        
        every { userService.createUser(request) } returns response
        
        // When & Then
        mockMvc.perform(
            post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
        )
            .andExpect(status().isCreated)
            .andExpect(jsonPath("$.name").value("John Doe"))
            .andExpect(jsonPath("$.email").value("[email protected]"))
    }
    
    @Test
    fun `should return validation error for invalid request`() {
        // Given
        val invalidRequest = CreateUserRequest("", "invalid-email")
        
        // When & Then
        mockMvc.perform(
            post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(invalidRequest))
        )
            .andExpect(status().isBadRequest)
            .andExpect(jsonPath("$.validationErrors.name").exists())
            .andExpect(jsonPath("$.validationErrors.email").exists())
    }
}

Security Integration

Basic Security Configuration

// SecurityConfig.kt (if using Spring Security)
package com.example.kotlinspringboot.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain

@Configuration
@EnableWebSecurity
class SecurityConfig {
    
    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
    
    @Bean
    fun userDetailsService(): UserDetailsService {
        val admin = User.builder()
            .username("admin")
            .password(passwordEncoder().encode("admin123"))
            .roles("ADMIN")
            .build()
            
        val user = User.builder()
            .username("user")
            .password(passwordEncoder().encode("user123"))
            .roles("USER")
            .build()
            
        return InMemoryUserDetailsManager(admin, user)
    }
    
    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .csrf { it.disable() }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .authorizeHttpRequests { auth ->
                auth.requestMatchers("/api/public/**", "/h2-console/**").permitAll()
                    .requestMatchers("/api/admin/**").hasRole("ADMIN")
                    .anyRequest().authenticated()
            }
            .httpBasic { }
            .headers { it.frameOptions().disable() } // For H2 console
            .build()
    }
}

Production Features

Health Checks and Monitoring

// HealthController.kt
package com.example.kotlinspringboot.controller

import org.springframework.boot.actuate.health.Health
import org.springframework.boot.actuate.health.HealthIndicator
import org.springframework.stereotype.Component
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/health")
class HealthController {
    
    @GetMapping
    fun health(): Map<String, Any> {
        return mapOf(
            "status" to "UP",
            "timestamp" to System.currentTimeMillis(),
            "service" to "kotlin-spring-boot-app"
        )
    }
}

@Component
class DatabaseHealthIndicator : HealthIndicator {
    
    override fun health(): Health {
        return try {
            // Check database connectivity
            // In real application, you would check actual database connection
            val isHealthy = checkDatabaseConnection()
            
            if (isHealthy) {
                Health.up()
                    .withDetail("database", "Available")
                    .withDetail("pool", "Active")
                    .build()
            } else {
                Health.down()
                    .withDetail("database", "Unavailable")
                    .build()
            }
        } catch (ex: Exception) {
            Health.down(ex)
                .withDetail("database", "Error")
                .build()
        }
    }
    
    private fun checkDatabaseConnection(): Boolean {
        // Implement actual database health check
        return true
    }
}

// Add to application.yml
# management:
#   endpoints:
#     web:
#       exposure:
#         include: health,info,metrics
#   endpoint:
#     health:
#       show-details: always

Caching

// CacheConfig.kt
package com.example.kotlinspringboot.config

import org.springframework.cache.CacheManager
import org.springframework.cache.annotation.EnableCaching
import org.springframework.cache.concurrent.ConcurrentMapCacheManager
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
@EnableCaching
class CacheConfig {
    
    @Bean
    fun cacheManager(): CacheManager {
        return ConcurrentMapCacheManager("users", "posts")
    }
}

// Enhanced UserService with caching
@Service
@Transactional
class CachedUserService(private val userRepository: UserRepository) {
    
    @Cacheable("users")
    @Transactional(readOnly = true)
    fun getUserById(id: Long): UserResponse {
        val user = userRepository.findById(id)
            .orElseThrow { ResourceNotFoundException("User not found with id: $id") }
        return mapToResponse(user)
    }
    
    @CacheEvict("users", key = "#id")
    fun deleteUser(id: Long) {
        if (!userRepository.existsById(id)) {
            throw ResourceNotFoundException("User not found with id: $id")
        }
        userRepository.deleteById(id)
    }
    
    @CachePut("users", key = "#result.id")
    fun updateUser(id: Long, request: UpdateUserRequest): UserResponse {
        // Implementation...
    }
}

Best Practices

Kotlin-Specific Spring Boot Patterns

// Use data classes for DTOs
data class ApiResponse<T>(
    val success: Boolean,
    val data: T? = null,
    val message: String? = null,
    val errors: List<String> = emptyList()
)

// Extension functions for cleaner code
fun <T> T.toSuccessResponse(message: String? = null) = ApiResponse(
    success = true,
    data = this,
    message = message
)

fun String.toErrorResponse() = ApiResponse<Nothing>(
    success = false,
    message = this
)

// Use companion objects for constants
class ApiConstants {
    companion object {
        const val DEFAULT_PAGE_SIZE = 20
        const val MAX_PAGE_SIZE = 100
        const val API_VERSION = "v1"
    }
}

// Leverage Kotlin's null safety
class SafeUserService(private val userRepository: UserRepository) {
    
    fun findUserSafely(id: Long): UserResponse? {
        return userRepository.findById(id)
            .takeIf { it.isPresent }
            ?.get()
            ?.let { mapToResponse(it) }
    }
    
    // Use let, run, apply scope functions appropriately
    fun createUserWithValidation(request: CreateUserRequest): UserResponse {
        return request.takeIf { it.name.isNotBlank() && it.email.isNotBlank() }
            ?.let { validRequest ->
                User(name = validRequest.name, email = validRequest.email)
                    .also { validateUniqueEmail(it.email) }
                    .let { userRepository.save(it) }
                    .let { mapToResponse(it) }
            }
            ?: throw IllegalArgumentException("Invalid user data")
    }
}

Key Takeaways

  • Spring Boot with Kotlin provides excellent developer experience and type safety
  • Use data classes for DTOs and entities to reduce boilerplate
  • Leverage Kotlin's null safety features throughout the application
  • Apply proper validation using Bean Validation annotations
  • Implement comprehensive error handling with global exception handlers
  • Use Spring Data JPA repositories for efficient data access
  • Test thoroughly with both unit and integration tests

Practice Exercises

  1. Build a complete CRUD API for a blog application with users, posts, and comments
  2. Implement JWT-based authentication and authorization
  3. Add caching and performance monitoring to a Spring Boot Kotlin application
  4. Create a reactive Spring WebFlux application using Kotlin coroutines

Quiz

  1. What are the key benefits of using Kotlin with Spring Boot?
  2. How do you handle validation in Spring Boot with Kotlin?
  3. What's the best way to handle nullable types in Spring Data JPA entities?
Show Answers
  1. Key benefits include null safety, concise syntax, data classes for DTOs, extension functions, and excellent Java interoperability.
  2. Use Bean Validation annotations (@NotBlank, @Email, etc.) on DTOs and handle validation errors with @RestControllerAdvice and MethodArgumentNotValidException.
  3. Use nullable types with default values and provide no-arg constructors for JPA compatibility, or use lateinit var for required non-null properties.