Skip to content
CalliCoder

Building REST APIs with Kotlin and Spring Boot

Published Updated Spring Boot 14 min read

The four Kotlin-specific things that break a JPA application — final classes, missing no-arg constructors, data classes as entities, and validation annotations landing on the wrong target — and the plugins that fix the first two.

Spring Boot and Kotlin fit together well, and the places they do not fit are all in the same corner: JPA. Kotlin’s defaults (final classes, no default constructor, immutable val properties) are each the opposite of what Hibernate needs, and the failures they cause are obscure enough to lose an afternoon to.

This builds a small CRUD API over MySQL and stops at each of those four points. Written against Spring Boot 3.2, Kotlin 1.9, Hibernate 6.4 and Java 17.

The build file

Two of the four problems are solved by compiler plugins, so the Gradle file matters more here than it does in Java:

plugins {
    id("org.springframework.boot") version "3.2.2"
    id("io.spring.dependency-management") version "1.1.4"
    kotlin("jvm") version "1.9.22"
    kotlin("plugin.spring") version "1.9.22"   // all-open
    kotlin("plugin.jpa") version "1.9.22"      // no-arg
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    runtimeOnly("com.mysql:mysql-connector-j")
}

kotlin {
    compilerOptions { freeCompilerArgs.add("-Xjsr305=strict") }
}

plugin.spring and plugin.jpa are not optional conveniences. Leaving either out produces a runtime failure with no obvious connection to Kotlin.

plugin.spring applies the all-open plugin to Spring’s stereotype annotations. Kotlin classes are final unless declared open, and Spring creates CGLIB subclasses to implement @Transactional, @Configuration and caching. Without the plugin you get:

Cannot subclass final class com.example.api.ArticleService

plugin.jpa applies no-arg, which synthesises the no-argument constructor Hibernate needs to instantiate an entity before populating it. Without it, Hibernate fails at bootstrap:

No default constructor for entity 'com.example.api.Article'

-Xjsr305=strict makes Kotlin treat Spring’s @Nullable / @NonNull annotations as real nullability information instead of platform types, which is what turns Spring’s own API into something the compiler can check.

Configuring MySQL

spring.datasource.url=jdbc:mysql://localhost:3306/articles_db?useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=secret

spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE

ddl-auto=update is for local iteration only. It will add columns and never remove or narrow one, so a schema built that way drifts from the entities in ways nothing reports. Anything past a prototype wants Flyway and ddl-auto=validate.

The two logging lines are worth keeping on during development: the first prints the generated SQL, the second prints the bound parameters, which is the only way to see what a query actually ran with.

The entity, and why it is not a data class

The reflex is to write the entity as a data class. Do not.

@Entity
@Table(name = "articles")
class Article(

    @Column(nullable = false)
    var title: String,

    @Column(columnDefinition = "TEXT")
    var content: String? = null,

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0
)

Three things there are deliberate.

It is a plain class. A data class generates equals, hashCode and toString over every constructor property. For an entity that is wrong in three separate ways: hashCode changes when the generated id is assigned on flush, which corrupts any HashSet the entity is already in; toString walks every property, so printing one entity triggers every lazy association it holds; and equals compares a lazy Hibernate proxy against a real instance and returns false for the same row. If you need equality, write it against the identifier by hand.

The properties are var, not val. Hibernate populates a loaded entity by setting fields. val compiles to a getter with no setter, and while Hibernate can reach the backing field directly, an immutable entity that Hibernate mutates is a lie the rest of your code will believe.

The id is last and has a default. Putting the generated identifier at the end with = 0 means Article("some title") compiles, which is what you want at a call site that is creating a new row. Long = 0 rather than Long? = null also keeps id non-nullable everywhere else.

Validation: the use-site target that matters

This is the Kotlin trap with the least helpful failure mode, because there is no failure. The validation is simply never applied.

data class ArticleRequest(
    @field:NotBlank(message = "Title is required")
    @field:Size(max = 200)
    val title: String,

    val content: String?
)

The field: prefix is load-bearing. A Kotlin constructor property generates a constructor parameter, a private field and a getter, and an annotation with no use-site target lands on the constructor parameter, where Bean Validation does not look. Write @NotBlank instead of @field:NotBlank and the request object validates clean no matter what you post to it.

Note that the request object is a data class. That is the right place for one: it is a value, it is never managed by Hibernate, and jackson-module-kotlin uses its primary constructor to deserialise, which is also what makes a non-null String property actually reject a missing JSON field instead of silently binding null into it.

The repository

interface ArticleRepository : JpaRepository<Article, Long> {
    fun findByTitleContainingIgnoreCase(fragment: String): List<Article>
    fun existsByTitle(title: String): Boolean
}

Nothing Kotlin-specific here, and that is the point: Spring Data derives the implementation from the method name identically. Returning Article? from a derived finder works and is more idiomatic than Optional<Article>.

The controller

@RestController
@RequestMapping("/api/articles")
class ArticleController(private val repository: ArticleRepository) {

    @GetMapping
    fun list(pageable: Pageable): Page<Article> = repository.findAll(pageable)

    @GetMapping("/{id}")
    fun get(@PathVariable id: Long): Article =
        repository.findById(id).orElseThrow { ArticleNotFoundException(id) }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun create(@Valid @RequestBody request: ArticleRequest): Article =
        repository.save(Article(title = request.title, content = request.content))

    @PutMapping("/{id}")
    fun update(@PathVariable id: Long, @Valid @RequestBody request: ArticleRequest): Article {
        val article = repository.findById(id).orElseThrow { ArticleNotFoundException(id) }
        article.title = request.title
        article.content = request.content
        return repository.save(article)
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    fun delete(@PathVariable id: Long) {
        if (!repository.existsById(id)) throw ArticleNotFoundException(id)
        repository.deleteById(id)
    }
}

Constructor injection needs no @Autowired, a single constructor is enough for Spring, and private val gives you an immutable dependency in one line.

Returning the entity directly is fine for a four-field example and stops being fine the moment the entity grows a lazy association or a field the client should not see. The same REST API structure in Java covers the response-model split in more detail.

One more place the final-by-default rule bites

plugin.spring opens classes annotated with Spring’s stereotypes. It does not open a function, and @Transactional is applied by a proxy that overrides the method:

@Service
class ArticleService(private val repository: ArticleRepository) {

    @Transactional
    private fun archive(id: Long) { }        // never proxied — private methods cannot be overridden

    @Transactional
    internal fun publish(id: Long) { }       // compiles to public, so this one does work
}

A private function carrying @Transactional runs with no transaction at all and reports nothing. The same is true of a call from one method of the class to another: self-invocation bypasses the proxy, so the annotation on the inner method is ignored. Both are Java problems too; they surface more in Kotlin because private is the reflexive visibility.

Errors that look like JSON

@ResponseStatus(HttpStatus.NOT_FOUND)
class ArticleNotFoundException(id: Long) : RuntimeException("No article with id $id")

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun onValidationError(ex: MethodArgumentNotValidException): ResponseEntity<Map<String, Any>> {
        val fields = ex.bindingResult.fieldErrors.associate { it.field to (it.defaultMessage ?: "invalid") }
        return ResponseEntity.badRequest().body(mapOf("status" to 400, "errors" to fields))
    }
}

Without the handler, a failed @Valid returns Spring’s default error body, which does not name the fields that failed. associate collapses the field errors into the map a client can actually render next to its inputs.

Running it

./gradlew bootRun

curl -s -X POST http://localhost:8080/api/articles \
  -H 'Content-Type: application/json' \
  -d '{"title":"Kotlin and JPA","content":"Four things to know."}'

curl -s http://localhost:8080/api/articles?page=0&size=10

Posting {"content":"no title"} returns 400 with {"errors":{"title":"Title is required"}}, and if it returns 201 instead, the field: prefix is missing.

Frequently asked questions

Why does Spring say “Cannot subclass final class”?

Kotlin classes are final by default and Spring needs a CGLIB subclass for @Transactional and @Configuration. Apply the kotlin("plugin.spring") plugin, which opens those classes automatically.

Why does Hibernate say there is no default constructor?

Kotlin does not generate a no-argument constructor when every property is a constructor parameter. kotlin("plugin.jpa") synthesises one for @Entity, @Embeddable and @MappedSuperclass.

Can I use a data class as a JPA entity?

It compiles, and it misbehaves. The generated hashCode changes when the id is assigned, toString triggers lazy loading, and equals fails between a proxy and a real instance. Use a plain class and write identity-based equality if you need it.

Should entity properties be val or var?

var. Hibernate mutates a managed entity when it loads and refreshes it; declaring the properties val states an immutability the persistence context does not honour.

Why is my @NotBlank annotation ignored?

It landed on the constructor parameter rather than the field. Write @field:NotBlank. Kotlin’s use-site targets decide where an annotation ends up, and Bean Validation reads the field.

Do I need jackson-module-kotlin?

For anything with a primary constructor and non-null properties, yes. Without it Jackson needs a no-arg constructor and mutable properties, and it will happily bind null into a non-null Kotlin type, producing a NullPointerException far from the request.

What does -Xjsr305=strict do?

It makes Kotlin honour JSR-305 nullability annotations on Java libraries as real types instead of platform types, so calling a Spring method that can return null becomes a compile-time concern.

Is Long? or Long better for a generated id?

var id: Long = 0 with the parameter last. It keeps the id non-nullable for every consumer, and 0 is an unambiguous “not persisted yet” for an IDENTITY column.

Can I use Kotlin coroutines with Spring Data JPA?

Not usefully: JPA is blocking, so a coroutine just parks a platform thread. Use spring-boot-starter-data-r2dbc with WebFlux for a genuinely non-blocking stack, or keep JPA and use virtual threads.

Does ddl-auto=update handle every schema change?

No. It adds tables and columns and never drops, renames or narrows one. A column you removed from the entity stays in the database, and nothing tells you the two have diverged.