A teammate writes JPA entities as Kotlin `data class`. Why is that problematic even with kotlin-jpa applied, and what entity-modeling guidance follows?
answer
- kotlin-jpa fixes mechanics, not semantics
- data class equals/hashCode/copy over ALL props
- Mutable-field hashCode breaks HashSet
- equals touching lazy assoc -> LazyInitException
- Use plain class, id-based equals, constant hashCode
basics
~20 skotlin-jpa makes entities open and gives them an empty constructor, but data classes auto-generate equals/hashCode/copy from all properties. With lazy loading and mutable IDs that breaks equality and causes surprises, so plain classes are recommended for entities.
solid answer
~50 skotlin-jpa fixes the mechanical problems (entities become `open` for proxies and get a synthetic no-args constructor), so a `data class` will *compile and run*. The deeper issue is semantics: `data class` auto-generates `equals`/`hashCode`/`copy`/`toString` from **all** primary-constructor properties. For entities that's wrong — identity should be based on the persistent ID, not mutable fields; `hashCode` over mutable fields breaks `HashSet`/`HashMap` membership as fields change; `equals` touching lazy associations triggers extra loading or `LazyInitializationException`; and `copy()` creates a detached clone with the same `@Id`, which corrupts persistence-context identity. Hibernate's lazy proxy subclass also doesn't see the real field values, so generated `equals` comparing fields misbehaves. Guidance: model entities as plain `class` (not `data class`), give them a stable surrogate `@Id`, write `equals`/`hashCode` based on that id (or use a base class), avoid `copy()`, and keep `final val` only where truly immutable.
code
kotlin · 11 lines// Anti-pattern
@Entity
data class Order(@Id @GeneratedValue val id: Long? = null, var total: Long)
// equals/hashCode over id+total (mutable), copy() clones same id
// Preferred
@Entity
class Order(@Id @GeneratedValue val id: Long? = null, var total: Long) {
override fun equals(o: Any?) = this === o || (o is Order && id != null && id == o.id)
override fun hashCode() = javaClass.hashCode()
}go deeper
May only know 'use class not data class for entities' as a rule.
Explains the equals/hashCode/copy generation and the HashSet hazard.
Connects lazy proxies, LazyInitializationException, persistence-context identity, and prescribes id-based equals / constant hashCode.
Sets team conventions/base entity classes, weighs surrogate vs natural keys, and codifies entity-modeling standards beyond the compiler plugins.
## Why it still 'works' but is wrong `kotlin-jpa` resolves the **mechanical** requirements: it makes `@Entity` classes `open` (lazy-loading proxies) and synthesizes a **no-args constructor** (reflective instantiation). So a `data class` entity compiles and Hibernate can instantiate it. The problem is **generated semantics**, not compilation. ## What `data class` generates A `data class` auto-derives, from **all** primary-constructor `val`/`var` properties: - `equals()` / `hashCode()` — structural over every property - `toString()` - `copy()` - `componentN()` (destructuring) Each collides with JPA's lifecycle: ### 1. equals/hashCode over mutable fields JPA entities mutate (`var title`, generated `@Id` assigned on flush). If `hashCode` depends on those, an entity placed in a `HashSet` before persist gets a different hash after the ID is assigned → it is "lost" in the set. **Entity identity must be ID-based and stable.** ### 2. Lazy associations Generated `equals`/`toString` touch **every** property, including `@OneToMany`/`@ManyToOne` lazy associations. Accessing them outside a session throws `LazyInitializationException`; inside one, it triggers unwanted N+1 loads. ### 3. Hibernate proxies The lazy proxy is a subclass whose fields are uninitialized; the real state lives behind getters. Auto-generated `equals` reads **fields directly**, so comparing a proxy to a real instance is unreliable. Correct entity `equals` uses getters / instanceof checks and the ID. ### 4. copy() `copy()` clones an entity keeping the same `@Id`. Now two managed-looking objects share an identity — a classic source of `NonUniqueObjectException` and confusion in the persistence context. ## Recommended entity modeling ```kotlin @Entity class Customer( @Id @GeneratedValue val id: Long? = null, var name: String, ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Customer) return false return id != null && id == other.id } override fun hashCode(): Int = javaClass.hashCode() } ``` - Plain `class`, not `data class`. - Stable surrogate `@Id` (nullable until generated). - `equals` based solely on `id`; `hashCode` constant per class (a common Hibernate-recommended pattern) so set membership survives ID assignment. - No `copy()`. ## Bottom line The compiler plugins make entities *mechanically* valid; they do nothing about the *behavioral* mismatch of `data class`. Treat entities as identity-bearing mutable objects, not value types.
- Why use a constant hashCode (javaClass.hashCode()) instead of id-based?Because id is null before persist; a constant hash keeps the entity findable in a HashSet across the transition from transient to persisted, at the cost of larger buckets.
- Is data class ever acceptable for persistence-related types?Yes — for DTOs, projections, and @Embeddable value objects with immutable fields where value semantics are exactly what you want.
data class is a value snapshot (like a receipt); an entity is a living account with a number — equality must be 'same account number', not 'same line items right now'.
saying these in an interview costs you the question
- Claiming kotlin-jpa makes data classes fully safe for entities
- Putting equals/hashCode over mutable or lazy fields
- Using copy() on managed entities
- Not knowing equals can trigger LazyInitializationException
- Treating an entity like an immutable value object