Spring Boot and Kotlin
Spring Boot officially supports Kotlin. Combining Kotlin’s concise syntax, null safety, and data classes with Spring Boot significantly reduces boilerplate compared to Java and produces safer code.
This article covers practical patterns including REST API construction, data class usage, and utility implementation with extension functions.
Project Setup
When you select Kotlin as the language in Spring Initializr, the required plugins are auto-configured. Let us look at the key Gradle configuration.
// build.gradle.kts — Key configuration
plugins {
id("org.springframework.boot") version "3.3.0"
id("io.spring.dependency-management") version "1.1.5"
kotlin("jvm") version "2.0.0"
kotlin("plugin.spring") version "2.0.0" // Auto-handles open classes
kotlin("plugin.jpa") version "2.0.0" // Auto-generates no-arg constructors
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
// Kotlin compiler options
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict") // Enhanced null safety
}
}
The kotlin-spring plugin automatically adds the open keyword required by Spring, and the kotlin-jpa plugin auto-generates the no-arg constructors needed for JPA entities.
Entity and DTO — Using Data Classes
Kotlin’s data class auto-generates equals, hashCode, toString, and copy for DTOs.
import jakarta.persistence.*
import jakarta.validation.constraints.*
import java.time.LocalDateTime
// JPA Entity — Use regular class instead of data class (JPA recommendation)
@Entity
@Table(name = "articles")
class Article(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
var title: String,
@Column(nullable = false, columnDefinition = "TEXT")
var content: String,
@Column(nullable = false)
var author: String,
@Column(nullable = false, updatable = false)
val createdAt: LocalDateTime = LocalDateTime.now(),
@Column(nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
)
// Request DTO — Concisely defined with data class
data class CreateArticleRequest(
@field:NotBlank(message = "Title is required")
@field:Size(max = 200, message = "Title must be 200 characters or less")
val title: String,
@field:NotBlank(message = "Content is required")
val content: String,
@field:NotBlank(message = "Author is required")
val author: String
)
data class UpdateArticleRequest(
val title: String?, // Nullable — supports partial updates
val content: String?
)
// Response DTO
data class ArticleResponse(
val id: Long,
val title: String,
val content: String,
val author: String,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime
) {
companion object {
// Factory method to convert entity to response DTO
fun from(article: Article) = ArticleResponse(
id = article.id,
title = article.title,
content = article.content,
author = article.author,
createdAt = article.createdAt,
updatedAt = article.updatedAt
)
}
}
// Pagination response
data class PageResponse<T>(
val content: List<T>,
val page: Int,
val size: Int,
val totalElements: Long,
val totalPages: Int
)
Using regular class instead of data class for JPA entities is recommended. The equals/hashCode generated by data class compares all fields, which can conflict with lazy loading.
REST Controller — Kotlin Style
Here is a Controller leveraging Kotlin’s expression functions and null safety.
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.*
import jakarta.validation.Valid
@RestController
@RequestMapping("/api/articles")
@Validated
class ArticleController(
private val articleService: ArticleService // Constructor injection (no @Autowired needed)
) {
// List — with pagination
@GetMapping
fun getArticles(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "10") size: Int
): ResponseEntity<PageResponse<ArticleResponse>> {
val result = articleService.getArticles(PageRequest.of(page, size))
return ResponseEntity.ok(result)
}
// Get single item
@GetMapping("/{id}")
fun getArticle(@PathVariable id: Long): ResponseEntity<ArticleResponse> =
ResponseEntity.ok(articleService.getArticle(id))
// Create
@PostMapping
fun createArticle(
@Valid @RequestBody request: CreateArticleRequest
): ResponseEntity<ArticleResponse> =
ResponseEntity
.status(HttpStatus.CREATED)
.body(articleService.createArticle(request))
// Update
@PutMapping("/{id}")
fun updateArticle(
@PathVariable id: Long,
@RequestBody request: UpdateArticleRequest
): ResponseEntity<ArticleResponse> =
ResponseEntity.ok(articleService.updateArticle(id, request))
// Delete
@DeleteMapping("/{id}")
fun deleteArticle(@PathVariable id: Long): ResponseEntity<Unit> {
articleService.deleteArticle(id)
return ResponseEntity.noContent().build()
}
}
In Kotlin, constructor injection is the default instead of @Autowired. Spring automatically injects when there is a single constructor.
Service Layer — Using Extension Functions
Here is a concise service layer using extension functions and scope functions like let/apply.
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class ArticleService(
private val articleRepository: ArticleRepository
) {
fun getArticles(pageable: Pageable): PageResponse<ArticleResponse> {
val page = articleRepository.findAll(pageable)
return PageResponse(
content = page.content.map { ArticleResponse.from(it) },
page = page.number,
size = page.size,
totalElements = page.totalElements,
totalPages = page.totalPages
)
}
fun getArticle(id: Long): ArticleResponse {
// findByIdOrNull — Kotlin extension function (provided by Spring Data)
val article = articleRepository.findByIdOrNull(id)
?: throw ArticleNotFoundException(id)
return ArticleResponse.from(article)
}
@Transactional
fun createArticle(request: CreateArticleRequest): ArticleResponse {
val article = Article(
title = request.title,
content = request.content,
author = request.author
)
return ArticleResponse.from(articleRepository.save(article))
}
@Transactional
fun updateArticle(id: Long, request: UpdateArticleRequest): ArticleResponse {
val article = articleRepository.findByIdOrNull(id)
?: throw ArticleNotFoundException(id)
// Partial update with apply — only update non-null fields
article.apply {
request.title?.let { title = it }
request.content?.let { content = it }
updatedAt = LocalDateTime.now()
}
return ArticleResponse.from(articleRepository.save(article))
}
@Transactional
fun deleteArticle(id: Long) {
if (!articleRepository.existsById(id)) {
throw ArticleNotFoundException(id)
}
articleRepository.deleteById(id)
}
}
// Custom exception
class ArticleNotFoundException(id: Long) :
RuntimeException("Article not found: id=$id")
Global Exception Handling
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
data class ErrorResponse(
val status: Int,
val message: String,
val errors: List<String> = emptyList()
)
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(ArticleNotFoundException::class)
fun handleNotFound(e: ArticleNotFoundException) =
ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse(404, e.message ?: "Resource not found"))
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(e: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
val errors = e.bindingResult.fieldErrors.map {
"${it.field}: ${it.defaultMessage}"
}
return ResponseEntity
.badRequest()
.body(ErrorResponse(400, "Invalid input", errors))
}
}
Practical Tips
- kotlin-spring plugin is essential: Automatically adds the
openkeyword required by Spring proxies. - kotlin-jpa plugin is essential: Auto-generates the no-arg constructors needed for JPA entities.
-Xjsr305=strict: Reflects Java’s@Nullable/@NotNullannotations in Kotlin’s type system.- Use
findByIdOrNull: WhilefindByIdreturnsOptional, the Kotlin extension functionfindByIdOrNullreturnsT?, which is more natural. - Constructor injection: In Kotlin, declaring dependencies in the primary constructor provides auto-injection without
@Autowired. - data class for DTOs only: Use regular classes for JPA entities and data classes only for request/response DTOs.