--- id: Kotlin/kotlin-agent-skills/kotlin-backend-jpa-entity-mapping version: "62a53916" license: Apache-2.0 install: manual updated: 2026-07-21 --- # kotlin-backend-jpa-entity-mapping — Master Spring Data JPA entity design in Kotlin by learning why regular classes outperform data classes for persistence, how to implement stable identity and equality semantics, and when to enforce uniqueness at both database and application layers. Covers fetch strategies, common ORM traps, and Kotlin-specific patterns for preventing N+1 queries and lazy-loading errors. Publisher: Kotlin · Stars: 971 · Updated: 2026-07-21 Install (manual): `git clone https://github.com/Kotlin/kotlin-agent-skills` ## SKILL.md # 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`. - For DB-generated IDs, model the unsaved state with nullable `var id: Long? = null` and a `protected set`; do not use `0L` as a sentinel value. - 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, // and the generated ID uses a 0 sentinel instead of null 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) var id: Long? = null protected set override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Order) return false return id != null && 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; DB-generated `id` is nullable with a protected setter ## 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) var id: Long? = null protected set } 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. [View on SkillFed](https://skillfed.io/Kotlin/kotlin-agent-skills/kotlin-backend-jpa-entity-mapping) · [View on GitHub](https://github.com/Kotlin/kotlin-agent-skills)