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.