Spring Boot + Kotlin Practical Guide

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 open keyword 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/@NotNull annotations in Kotlin’s type system.
  • Use findByIdOrNull: While findById returns Optional, the Kotlin extension function findByIdOrNull returns T?, 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.

Was this article helpful?