Why using Kotlin data classes as Entities is a bad idea
Kotlin data classes have been an awesome language addition—so good that they inspired Java 14’s record classes. Kotlin data classes are a great choice to create DTOs or Value Objects, but some of their features don’t go hand in hand with JPA entities. Let’s look at why it’s a bad idea to make JPA entities data classes.
Implicit hash code change
To understand this problem, we need to look at one of the features data classes provide. A data class automatically
creates equals()
/hashCode()
for your class, based on attributes declared in the primary constructor. Consider
this data class: data class User(val name: String, val age: Int)
. The hash code will be derived from name + age
—simple enough.
Now let’s make an entity User. It could look like this:
@Entity
data class User(
@Id
@GeneratedValue
val id: Int? = null,
val name: String,
val age: Int
)
Notice that id
is also declared in the primary constructor, so it will be part of hashCode()
. What happens when we persist an entity like this?
The database assigns the next available id, and Hibernate updates our object under the hood—implicitly changing hashCode()
.
val user = User(name = "Damian", age = 18)
user.hashCode() // 564832885
userRepository.save(user)
user.hashCode() // 564833846
If you rely on equals()
/hashCode()
to compare your entities, you might be surprised that your entity is flagged as changed just because it was saved.
How to prevent this?
- don’t use database-generated IDs—switch to client‑side generated UUIDs (that also comes with other benefits)
- don’t put your ID in the constructor—only parameters from the primary constructor are used in
equals()
/hashCode()
- provide your own implementation of
equals()
/hashCode()
- or simply don’t use data classes as entities
Unnecessarily loading lazy relations
Let’s look at another data class feature: automatic toString()
implementation. Similar to equals()
/hashCode()
, only parameters from the primary constructor are
part of toString()
. This by itself isn’t bad—the issue arises when it’s combined with Hibernate’s lazy loading.
Hibernate’s OneToMany
relations are fetched lazily by default. Hibernate won’t load the data until it’s needed. It all happens under the hood, without
developer involvement. That saves precious time by avoiding database hits when the data isn’t used. So what triggers lazy relations to load? Basically, any
attempt to use the data—including when toString()
is called on a property. Let’s look at these classes and their relation:
@Entity
data class User(
@Id
@GeneratedValue
val id: Int? = null,
val name: String,
@OneToMany(mappedBy = "user")
val addresses: List<Address> = emptyList()
)
@Entity
data class Address(
@Id
@GeneratedValue
val id: Int? = null,
val name: String,
@ManyToOne
val user: User? = null
)
Users have multiple addresses, and each address is stored in a separate entity. They are connected via the Hibernate OneToMany
relation. The list of
addresses is part of the primary constructor, meaning this field is included in toString()
. Let’s see what Hibernate will do when we load a
user and call toString()
on the object.
val user = userRepository.findAll().single()
print(user.name) // triggers: select id, name from user
user.toString() // triggers: select id, name, user from address
Notice that the first select omits addresses
even though it’s part of the entity. Addresses are only loaded when we call
toString()
on the object. You probably won’t explicitly call toString()
on an entity,
but you might have an AOP logger that logs the output of a command/query. It’s easy to trigger that call accidentally, causing a completely
unnecessary hit to the database.
How to prevent this?
- don’t put your relations in the constructor—only parameters from the primary constructor are used in
toString()
- provide your own implementation of
toString()
- or simply don’t use data classes as entities
Conclusion
Although there are ways to overcome the issues I’ve presented and still use data classes as JPA entities, doing so leaves room for error. Someone else can easily add new fields to the entity’s primary constructor without noticing that it’s a data class.
Usually, if you choose to use a data class, high maintainability is one of the top reasons. But when choosing a data class for an entity, we get the opposite—a class that’s tricky to maintain. Considering it all, I highly recommend avoiding data classes for entities altogether.