A Kotlin data class turns a Spring Boot DTO into a few lines, and the same few lines quietly break your JPA entity.
I compiled each class below with and without the Kotlin compiler plugins and read the bytecode and the Spring and Hibernate code that loads them. I reproduced every problem below on Spring Boot 4.1.1 (Hibernate 7.4.5, Jackson 3.1.5) and Kotlin 2.3.21 on Java 21.
What a data class becomes in Java
data class Customer(var id: Long? = null, var name: String)Decompiled and trimmed, that’s this Java class:
public final class Customer {
private Long id;
private String name;
public Customer(Long id, String name) {
Intrinsics.checkNotNullParameter(name, "name");
this.id = id;
this.name = name;
}
public final Long getId() { return id; }
public final String getName() { return name; }
public final Customer copy(Long id, String name) { return new Customer(id, name); }
// plus setters, component1(), component2(), a synthetic constructor for defaults,
// and equals(), hashCode(), toString() over id and name
}One detail matters later: the class and all its methods are final.
How Spring reads it
Spring doesn’t pick a constructor by JVM rules alone. With kotlin-reflect on the classpath it finds the Kotlin primary constructor and calls it through callBy. Optional parameters that got no value are left out, so Kotlin defaults apply. Spring Boot binds configuration properties the same way.
Where data classes fit well
@ConfigurationProperties("mail")
data class MailProperties(
val host: String = "localhost",
val port: Int = 25,
val timeout: Duration = Duration.ofSeconds(5),
)Set only the port to 587 and the host and timeout keep their defaults. A required property without a default stops the startup with a NullPointerException from the constructor’s null check. The message names the parameter, not the property key.
Request and response DTOs are the other good fit: value equality and a constructor that refuses null for non-null fields.
Problem 1: final entities lose lazy loading
@Entity
data class Supplier(
@Id @GeneratedValue var id: Long? = null,
var name: String = "",
)
@Entity
data class Shipment(
@Id @GeneratedValue var id: Long? = null,
@ManyToOne(fetch = FetchType.LAZY) var supplier: Supplier? = null, // loaded at once
)Without the compiler plugins Supplier is final. Hibernate can’t subclass it for a proxy, so it turns lazy loading off for that class. Loading a shipment runs a second SELECT for its supplier, and nothing in the log warns you.
If a constructor parameter has no default, there is also no no-arg constructor, and the first load fails with InstantiationException.
The fix is the jpa plugin plus all-open for entities. start.spring.io adds both; a build set up by hand needs them:
<!-- pom.xml, kotlin-maven-plugin: add to the existing lists -->
<configuration>
<compilerPlugins>
<plugin>jpa</plugin>
<plugin>all-open</plugin>
</compilerPlugins>
<pluginOptions>
<option>all-open:annotation=jakarta.persistence.Entity</option>
<option>all-open:annotation=jakarta.persistence.MappedSuperclass</option>
<option>all-open:annotation=jakarta.persistence.Embeddable</option>
</pluginOptions>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>// build.gradle.kts
plugins {
kotlin("plugin.jpa") version "2.3.21"
kotlin("plugin.allopen") version "2.3.21"
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}Problem 2: the plugin’s constructor skips initializers
With the plugins on, take this entity:
@Entity
data class Customer(
@Id @GeneratedValue var id: Long? = null,
var name: String,
) {
@Transient
val tags: MutableList<String> = mutableListOf()
}Hibernate creates it through the no-arg constructor the jpa plugin adds. In bytecode:
public Customer();
0: aload_0
1: invokespecial java/lang/Object."<init>":()V
4: returnIn Java:
public Customer() {
super();
// tags is never assigned
}After a load, tags is null although its type is non-null, and the first add throws NullPointerException. Give every constructor parameter a default, or let the no-arg plugin run initializers. That option is off by default, and start.spring.io doesn’t turn it on.
<!-- pom.xml, kotlin-maven-plugin: add to the existing lists -->
<compilerPlugins>
<plugin>no-arg</plugin>
</compilerPlugins>
<pluginOptions>
<option>no-arg:annotation=jakarta.persistence.Entity</option>
<option>no-arg:invokeInitializers=true</option>
</pluginOptions>// build.gradle.kts, next to kotlin("plugin.jpa")
noArg {
invokeInitializers = true
}Then the generated constructor becomes:
public Customer() {
super();
this.tags = new ArrayList<>();
}Problem 3: generated methods on an entity
Generated equals and hashCode use every constructor property, including the id Hibernate generates and lazy associations:
@Entity
data class Author(
@Id @GeneratedValue(strategy = GenerationType.UUID) var id: UUID? = null,
var name: String = "",
@OneToMany(mappedBy = "author") val books: MutableList<Book> = mutableListOf(),
)
@Entity
data class Book(
@Id @GeneratedValue(strategy = GenerationType.UUID) var id: UUID? = null,
var title: String = "",
@ManyToOne(fetch = FetchType.LAZY) var author: Author? = null,
)val book = Book(title = "Kotlin in Action")
val seen = hashSetOf(book)
val em = emf.createEntityManager()
em.transaction.begin()
em.persist(book)
em.flush()
em.transaction.commit()
seen.contains(book) // false: the generated id changed hashCode
val author = Author(name = "Dmitry")
author.books += Book(title = "JPA", author = author)
author.hashCode() // StackOverflowError: Author and Book hash each otherAnd toString on a book loaded in a session that is now closed reads the lazy author and throws LazyInitializationException. More cases are in Spring Data JPA with Kotlin.
Problem 4: @NotBlank that never runs
The two properties differ only in the use-site target:
data class SignupRequest(
@NotBlank val email: String, // constructor parameter: validate() skips it
@field:NotBlank val name: String, // goes to the field: checked
)Validate a request with both values empty and only the name fails. The bytecode shows where the annotation went:
private final java.lang.String email;
RuntimeInvisibleAnnotations: org.jetbrains.annotations.NotNull
public SignupRequest(java.lang.String, java.lang.String);
RuntimeVisibleParameterAnnotations:
parameter 0: jakarta.validation.constraints.NotBlankIn Java:
public final class SignupRequest {
private final String email; // no @NotBlank here
@NotBlank
private final String name;
public SignupRequest(@NotBlank String email, String name) { /* ... */ }
}Validating the object checks fields and getters, not constructor parameters. Put the field target on every constraint, as on the name. It works with any build.
A compiler flag puts the annotation on the field as well. It is off by default in Kotlin 2.3. start.spring.io adds it to new projects (checked October 2026); older or hand-made builds don’t have it. Check your build:
<!-- pom.xml, kotlin-maven-plugin configuration -->
<args>
<arg>-Xannotation-default-target=param-property</arg>
</args>// build.gradle.kts
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xannotation-default-target=param-property")
}
}Problem 5: Jackson without the Kotlin module
data class PriceRequest(
val amount: Long,
val currency: String = "EUR",
)Jackson 3 finds the constructor by itself, but it doesn’t know Kotlin defaults. For {"amount":100} it calls this:
new PriceRequest(100L, null); // NPE from checkNotNullParameter, wrapped in ValueInstantiationExceptionSpring Boot 4 registers jackson-module-kotlin when the jar is on the classpath. start.spring.io adds the jar only with the web starter. A project generated without the web starter that adds Jackson later, or a JsonMapper you build yourself in a test, hits this. Add the Kotlin module to every mapper you build:
val mapper = JsonMapper.builder()
.addModule(KotlinModule.Builder().build()) // applies currency = "EUR"
.build()Good practices
- Make entities regular classes with the jpa and all-open plugins. Don’t override equals and hashCode, and keep associations out of toString.
- If a data class entity has to stay, give every constructor parameter a default or turn on invokeInitializers.
- Add the Kotlin module to every JsonMapper you build yourself, in tests too.
- Put validation annotations on the field with
@field:, or turn on the param-property flag.
Have you found a data class entity in your project, and what broke first?
At the end
If you like this post, follow me on LinkedIn to get new articles about Kotlin and Spring. More on entities in Kotlin: Spring Data JPA with Kotlin.
This post is part of my Spring Data JPA guide.
Found this useful? Follow me on social media to stay updated.
