--- name: kotlin-backend-jpa-entity-mapping description: > Model Kotlin persistence code correctly for Spring Data JPA and Hibernate. Covers entity design, identity and equality, uniqueness constraints, relationships, fetch plans, and common ORM (Object-Relational Mapping) traps specific to Kotlin. Use when creating or reviewing JPA (Java Persistence API) entities, diagnosing N+1 or LazyInitializationException, placing indexes and uniqueness rules, or preventing Kotlin-specific bugs such as data class entities and broken equals/hashCode. license: Apache-2.0 metadata: author: JetBrains version: "1.0.0" --- # JPA Entity Mapping for Kotlin Kotlin's `data class` is natural for DTOs but dangerous for JPA entities. Hibernate relies on identity semantics that `data class` breaks: `equals`/`hashCode` over all fields corrupts `Set`/`Map` membership after state changes, and auto-generated `copy()` creates detached duplicates of managed entities. This skill teaches correct entity design, identity strategies, and uniqueness constraints for Kotlin + Spring Data JPA projects. ## Entity Design Rules - **Never use `data class` for JPA entities.** Use a regular `class`. Keep `data class` for DTOs. - Keep transport DTOs and persistence entities separate unless the project clearly uses a shared model. - Model required columns as non-null only when object construction and persistence lifecycle make it safe. - Use `lateinit` only when the project already accepts that tradeoff and the lifecycle is safe. - Verify `kotlin("plugin.jpa")` or equivalent no-arg support when JPA entities exist. - Verify classes and members are compatible with proxying where needed. ## Identity and Equality - Never accept all-field `equals`/`hashCode` generated by `data class` on an entity. - Follow project conventions when they already define an identity strategy. - If no convention exists, use ID-based equality with a stable `hashCode`. - Be explicit about mutable fields and lazy associations when discussing equality. ### Broken: `data class` Entity ```kotlin // WRONG: data class generates equals/hashCode from ALL fields data class Order( @Id @GeneratedValue val id: Long = 0, var status: String, var total: BigDecimal ) // BUG: order.status = "SHIPPED"; set.contains(order) → false (hash changed) // BUG: Hibernate proxy.equals(entity) → false (proxy has lazy fields uninitialized) ``` ### Correct: Regular Class with ID-Based Identity ```kotlin @Entity @Table(name = "orders") class Order( @Column(nullable = false) var status: String, @Column(nullable = false) var total: BigDecimal ) { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0 override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Order) return false return id != 0L && id == other.id } override fun hashCode(): Int = javaClass.hashCode() // toString must NOT reference lazy collections override fun toString(): String = "Order(id=$id, status=$status)" } ``` **Key rules:** - `equals` compares by ID only — stable under dirty tracking and proxy unwrapping - `hashCode` returns class-based constant — avoids `Set`/`Map` corruption after persist - `toString` excludes lazy-loaded relations — prevents `LazyInitializationException` - Constructor params are mutable entity fields; `id` is `val` with default ## Uniqueness Constraints When an API must be idempotent (e.g., "reserve stock for order X"), enforce uniqueness at both layers: database constraint for correctness, application check for clean errors. ### Broken: No Duplicate Guard ```kotlin @Service class ReservationService(private val repo: ReservationRepository) { @Transactional fun createReservation(variantId: Long, orderId: String, qty: Int): Reservation { // BUG: no check — duplicates silently accumulate return repo.save(Reservation(variantId = variantId, orderId = orderId, quantity = qty)) } } ``` ### Correct: Database Constraint + Application Guard ```kotlin @Entity @Table( name = "reservations", uniqueConstraints = [ UniqueConstraint(columnNames = ["variant_id", "order_id"]) ] ) class Reservation( @Column(name = "variant_id", nullable = false) val variantId: Long, @Column(name = "order_id", nullable = false) val orderId: String, @Column(nullable = false) var quantity: Int ) { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0 } interface ReservationRepository : JpaRepository { fun findByVariantIdAndOrderId(variantId: Long, orderId: String): Reservation? } @Service class ReservationService(private val repo: ReservationRepository) { @Transactional fun createReservation(variantId: Long, orderId: String, qty: Int): Reservation { repo.findByVariantIdAndOrderId(variantId, orderId)?.let { throw IllegalStateException( "Reservation already exists for variant=$variantId, order=$orderId" ) } return repo.save(Reservation(variantId = variantId, orderId = orderId, quantity = qty)) } } ``` **Key rules:** - Database constraint is mandatory — application checks alone have race conditions - Application check provides clean error messages — without it, users get raw `DataIntegrityViolationException` - Both layers together: application catches the common case, database catches the race - Spring Data derives `findByXAndY` queries automatically ## Query and Fetch Rules - Diagnose N+1 by looking at actual query count or SQL logs, not by guessing from annotations. - Prefer targeted fetch solutions: `@EntityGraph`, `JOIN FETCH`, batch fetching, or DTO projection. - Be careful with collection fetch joins plus pagination — call out the tradeoff. - Use indexes and uniqueness constraints to support real query patterns. ## Common ORM Traps - **Bidirectional associations:** maintain both sides in domain methods. Half-updated graphs cause subtle bugs. - **`orphanRemoval` vs cascade remove:** not interchangeable. Explain lifecycle semantics before choosing. - **Lazy load triggers:** `toString`, debug logging, JSON serialization, and IDE inspection can all trigger lazy loads. - **Bulk updates/deletes:** bypass persistence context and lifecycle callbacks. Subsequent reads may be stale. - **Multiple bag fetches:** can cause Cartesian explosion. Verify the ORM can execute collection-heavy fetch plans safely. - **`Set` + mutable equality:** collection membership can break after entity state changes. - **`@Version`:** the clearest optimistic concurrency mechanism when concurrent updates matter. - **`open-in-view` disabled:** DTO mapping touching lazy fields must happen inside a transaction boundary. ## Guardrails - Do not use `data class` for JPA entities. - Do not recommend `FetchType.EAGER` everywhere to silence lazy loading symptoms. - Do not expose entities directly through API responses by default. - Do not claim an N+1 fix without explaining how the fetch plan changes query behavior.