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.md
source 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
    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

// 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:

  • 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

@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
    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.