Skill v1.0.2
currentAutomated scan100/100+1 new
version: "1.0.2" 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. Keepdata classfor 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
lateinitonly 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/hashCodegenerated bydata classon 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
// WRONG: data class generates equals/hashCode from ALL fields,// and the generated ID uses a 0 sentinel instead of nulldata 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
@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? = nullprotected setoverride fun equals(other: Any?): Boolean {if (this === other) return trueif (other !is Order) return falsereturn id != null && id == other.id}override fun hashCode(): Int = javaClass.hashCode()// toString must NOT reference lazy collectionsoverride fun toString(): String = "Order(id=$id, status=$status)"}
Key rules:
equalscompares by ID only — stable under dirty tracking and proxy unwrappinghashCodereturns class-based constant — avoidsSet/Mapcorruption after persisttoStringexcludes lazy-loaded relations — preventsLazyInitializationException- Constructor params are mutable entity fields; DB-generated
idis 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
@Serviceclass ReservationService(private val repo: ReservationRepository) {@Transactionalfun createReservation(variantId: Long, orderId: String, qty: Int): Reservation {// BUG: no check — duplicates silently accumulatereturn repo.save(Reservation(variantId = variantId, orderId = orderId, quantity = qty))}}
Correct: Database Constraint + Application Guard
@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? = nullprotected set}interface ReservationRepository : JpaRepository<Reservation, Long> {fun findByVariantIdAndOrderId(variantId: Long, orderId: String): Reservation?}@Serviceclass ReservationService(private val repo: ReservationRepository) {@Transactionalfun 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
findByXAndYqueries 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 classfor JPA entities. - Do not recommend
FetchType.EAGEReverywhere 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.