$npx -y skills add Kotlin/kotlin-agent-skills --skill kotlin-backend-jpa-entity-mappingModel 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 (Jav
| 1 | # JPA Entity Mapping for Kotlin |
| 2 | |
| 3 | Kotlin's `data class` is natural for DTOs but dangerous for JPA entities. Hibernate relies on |
| 4 | identity semantics that `data class` breaks: `equals`/`hashCode` over all fields corrupts |
| 5 | `Set`/`Map` membership after state changes, and auto-generated `copy()` creates detached |
| 6 | duplicates of managed entities. |
| 7 | |
| 8 | This skill teaches correct entity design, identity strategies, and uniqueness constraints |
| 9 | for Kotlin + Spring Data JPA projects. |
| 10 | |
| 11 | ## Entity Design Rules |
| 12 | |
| 13 | - **Never use `data class` for JPA entities.** Use a regular `class`. Keep `data class` for DTOs. |
| 14 | - Keep transport DTOs and persistence entities separate unless the project clearly uses a shared model. |
| 15 | - Model required columns as non-null only when object construction and persistence lifecycle make it safe. |
| 16 | - Use `lateinit` only when the project already accepts that tradeoff and the lifecycle is safe. |
| 17 | - Verify `kotlin("plugin.jpa")` or equivalent no-arg support when JPA entities exist. |
| 18 | - Verify classes and members are compatible with proxying where needed. |
| 19 | |
| 20 | ## Identity and Equality |
| 21 | |
| 22 | - Never accept all-field `equals`/`hashCode` generated by `data class` on an entity. |
| 23 | - Follow project conventions when they already define an identity strategy. |
| 24 | - If no convention exists, use ID-based equality with a stable `hashCode`. |
| 25 | - For DB-generated IDs, model the unsaved state with nullable `var id: Long? = null` |
| 26 | and a `protected set`; do not use `0L` as a sentinel value. |
| 27 | - Be explicit about mutable fields and lazy associations when discussing equality. |
| 28 | |
| 29 | ### Broken: `data class` Entity |
| 30 | |
| 31 | ```kotlin |
| 32 | // WRONG: data class generates equals/hashCode from ALL fields, |
| 33 | // and the generated ID uses a 0 sentinel instead of null |
| 34 | data class Order( |
| 35 | @Id @GeneratedValue val id: Long = 0, |
| 36 | var status: String, |
| 37 | var total: BigDecimal |
| 38 | ) |
| 39 | // BUG: order.status = "SHIPPED"; set.contains(order) → false (hash changed) |
| 40 | // BUG: Hibernate proxy.equals(entity) → false (proxy has lazy fields uninitialized) |
| 41 | ``` |
| 42 | |
| 43 | ### Correct: Regular Class with ID-Based Identity |
| 44 | |
| 45 | ```kotlin |
| 46 | @Entity |
| 47 | @Table(name = "orders") |
| 48 | class Order( |
| 49 | @Column(nullable = false) |
| 50 | var status: String, |
| 51 | |
| 52 | @Column(nullable = false) |
| 53 | var total: BigDecimal |
| 54 | ) { |
| 55 | @Id |
| 56 | @GeneratedValue(strategy = GenerationType.IDENTITY) |
| 57 | var id: Long? = null |
| 58 | protected set |
| 59 | |
| 60 | override fun equals(other: Any?): Boolean { |
| 61 | if (this === other) return true |
| 62 | if (other !is Order) return false |
| 63 | return id != null && id == other.id |
| 64 | } |
| 65 | |
| 66 | override fun hashCode(): Int = javaClass.hashCode() |
| 67 | |
| 68 | // toString must NOT reference lazy collections |
| 69 | override fun toString(): String = "Order(id=$id, status=$status)" |
| 70 | } |
| 71 | ``` |
| 72 | |
| 73 | **Key rules:** |
| 74 | - `equals` compares by ID only — stable under dirty tracking and proxy unwrapping |
| 75 | - `hashCode` returns class-based constant — avoids `Set`/`Map` corruption after persist |
| 76 | - `toString` excludes lazy-loaded relations — prevents `LazyInitializationException` |
| 77 | - Constructor params are mutable entity fields; DB-generated `id` is nullable with a protected setter |
| 78 | |
| 79 | ## Uniqueness Constraints |
| 80 | |
| 81 | When an API must be idempotent (e.g., "reserve stock for order X"), enforce uniqueness |
| 82 | at both layers: database constraint for correctness, application check for clean errors. |
| 83 | |
| 84 | ### Broken: No Duplicate Guard |
| 85 | |
| 86 | ```kotlin |
| 87 | @Service |
| 88 | class ReservationService(private val repo: ReservationRepository) { |
| 89 | @Transactional |
| 90 | fun createReservation(variantId: Long, orderId: String, qty: Int): Reservation { |
| 91 | // BUG: no check — duplicates silently accumulate |
| 92 | return repo.save(Reservation(variantId = variantId, orderId = orderId, quantity = qty)) |
| 93 | } |
| 94 | } |
| 95 | ``` |
| 96 | |
| 97 | ### Correct: Database Constraint + Application Guard |
| 98 | |
| 99 | ```kotlin |
| 100 | @Entity |
| 101 | @Table( |
| 102 | name = "reservations", |
| 103 | uniqueConstraints = [ |
| 104 | UniqueConstraint(columnNames = ["variant_id", "order_id"]) |
| 105 | ] |
| 106 | ) |
| 107 | class Reservation( |
| 108 | @Column(name = "variant_id", nullable = false) |
| 109 | val variantId: Long, |
| 110 | |
| 111 | @Column(name = "order_id", nullable = false) |
| 112 | val orderId: String, |
| 113 | |
| 114 | @Column(nullable = false) |
| 115 | var quantity: Int |
| 116 | ) { |
| 117 | @Id @GeneratedValue(strategy = GenerationType.IDENTITY) |
| 118 | var id: Long? = null |
| 119 | protected set |
| 120 | } |
| 121 | |
| 122 | interface ReservationRepository : JpaRepository<Reservation, Long> { |
| 123 | fun findByVariantIdAndOrderId(variantId: Long, orderId: String): Reservation? |
| 124 | } |
| 125 | |
| 126 | @Ser |