Kotlin-agent-skills kotlin-backend-jpa-entity-mapping
install
source · Clone the upstream repo
git clone https://github.com/Kotlin/kotlin-agent-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/Kotlin/kotlin-agent-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/kotlin-backend-jpa-entity-mapping" ~/.claude/skills/kotlin-kotlin-agent-skills-kotlin-backend-jpa-entity-mapping && rm -rf "$T"
manifest:
skills/kotlin-backend-jpa-entity-mapping/SKILL.mdsource content
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
for JPA entities. Use a regulardata class
. Keepclass
for DTOs.data class - 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
only when the project already accepts that tradeoff and the lifecycle is safe.lateinit - Verify
or equivalent no-arg support when JPA entities exist.kotlin("plugin.jpa") - Verify classes and members are compatible with proxying where needed.
Identity and Equality
- Never accept all-field
/equals
generated byhashCode
on an entity.data class - 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
data class// 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
@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:
compares by ID only — stable under dirty tracking and proxy unwrappingequals
returns class-based constant — avoidshashCode
/Set
corruption after persistMap
excludes lazy-loaded relations — preventstoStringLazyInitializationException- Constructor params are mutable entity fields;
isid
with defaultval
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
@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
@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<Reservation, Long> { 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
queries automaticallyfindByXAndY
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
, batch fetching, or DTO projection.JOIN FETCH - 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.
vs cascade remove: not interchangeable. Explain lifecycle semantics before choosing.orphanRemoval- Lazy load triggers:
, debug logging, JSON serialization, and IDE inspection can all trigger lazy loads.toString - 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.
+ mutable equality: collection membership can break after entity state changes.Set
: the clearest optimistic concurrency mechanism when concurrent updates matter.@Version
disabled: DTO mapping touching lazy fields must happen inside a transaction boundary.open-in-view
Guardrails
- Do not use
for JPA entities.data class - Do not recommend
everywhere to silence lazy loading symptoms.FetchType.EAGER - 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.