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
- Build a complete CRUD API for a blog application with users, posts, and comments
- Implement JWT-based authentication and authorization
- Add caching and performance monitoring to a Spring Boot Kotlin application
- Create a reactive Spring WebFlux application using Kotlin coroutines
Quiz
- What are the key benefits of using Kotlin with Spring Boot?
- How do you handle validation in Spring Boot with Kotlin?
- What's the best way to handle nullable types in Spring Data JPA entities?
Show Answers
- Key benefits include null safety, concise syntax, data classes for DTOs, extension functions, and excellent Java interoperability.
- Use Bean Validation annotations (@NotBlank, @Email, etc.) on DTOs and handle validation errors with @RestControllerAdvice and MethodArgumentNotValidException.
- Use nullable types with default values and provide no-arg constructors for JPA compatibility, or use lateinit var for required non-null properties.