Why using Kotlin data classes as Entities is a bad idea
· 4 min read
Kotlin data classes have been an awesome language addition, so good that they were also introduced in Java 14 in the form of record classes. Kotlin data classes are a great choice to easily create Data Transfer Objects or Value Objects, but some of their features do not go hand in hand with JPA entities. Let's take a look at why, and why it's a bad idea to make JPA entities data classes.
Implicit hash code change
To better understand this problem we need to take a look at one of the features that data classes provide. Data class will automatically
create equals()/hashCode()
methods for your class, based on attributes declared in the primary constructor. Let's take a look at
this data class: data class User(val name: String, val age: Int)
, hashCode of this class will be derived from name + age
, that's 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
)
Please notice that id is also declared in the primary constructor, so it will be also a part of hashCode()
. What will happen when we persist entity like this?
Database will assign the next available id, and hibernate will update 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 will be 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 solution also comes with other benefits)
- don't put your id in constructor - only parameters from primary constructor are used in
equals()/hashCode()
- provide your own implementation of
equals()/hashCode()
- just don't use data classes as entities
Unnececaryly loading lazy relations
Let's take a look at another data class feature, automatic toString()
implementation. Similarly to equals()/hashCode()
only parameters from the primary constructor are
a part of toString()
. This in itself is not a bad thing, the issue arises whenever this feature is combined with Hibernate's lazy loading.
Hibernate's OneToMany
relations by default are fetched in a lazy manner. This means that Hibernate won't load the data until it's indeed needed. It all happens under the hood, without
any involvement from the developer. That saves precious time to load additional data from the database whenever it's not used. So what triggers lazy relations to be loaded? Basically, any
attempt to use the data - this also includes whenever toString()
is called on a property. Let's take a 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
)
User have have multiple Addresses and each address is stored in a separate entity. They are connected via the Hibernate relation OneToMany
. List of
addresses is a part of the primary constructor, meaning this field is included in toString()
. Let's see what will Hibenrate do whenever we load
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
Please notice that first select omits addresses
even it's a part of an entity. Address will be only loaded only when we call
toString()
on an object. You will probably very really explicitly call toString()
on an entity,
but you might have AOP logger that will log the output of a Command/ Query. So there is an option to accidentally trigger that call, causing a completely
unnecessary call to the database.
How to prevent this?
- don't put your relations in constructor - only parameters from the primary constructor are used in
toString()
- provide your own implementation of
toString()
- just don't use data classes as Entities
Conclusion
Altho there are some options to overcome issues I've presented and still use data classes as JPA entities. But by doing so we are leaving room for an error. Someone else can easily add later new fields to the entity's primary constructor without noticing that it's a data class.
Usually, if you choose to use data class high maintainability would be one of the very top reasons to do so, but choosing data class for an entity we get an opposite result - a class that is very tricky to maintain. Considering it all I would highly recommend just avoiding data classes in entities altogether.