kotlin-backend-jpa-entity-mapping

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

JPA Entity Mapping for Kotlin

Kotlin的JPA实体映射

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.
Kotlin的
data class
非常适合作为DTO,但用于JPA实体则存在风险。Hibernate依赖的标识语义会被
data class
破坏:基于所有字段生成的
equals
/
hashCode
会在状态变更后导致
Set
/
Map
的成员关系失效,而自动生成的
copy()
方法会创建托管实体的游离副本。
本内容将教授Kotlin + Spring Data JPA项目中正确的实体设计、标识策略和唯一性约束。

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.
  • 切勿将
    data class
    用于JPA实体
    。请使用普通
    class
    data class
    仅用于DTO。
  • 除非项目明确使用共享模型,否则请将传输DTO与持久化实体分开。
  • 仅当对象构造和持久化生命周期确保安全时,才将必填列建模为非空类型。
  • 仅当项目已接受相关权衡且生命周期安全时,才使用
    lateinit
  • 当存在JPA实体时,请验证是否启用了
    kotlin("plugin.jpa")
    或等效的无参构造函数支持。
  • 验证类和成员是否与所需的代理机制兼容。

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.
  • 切勿在实体上使用
    data class
    生成的全字段
    equals
    /
    hashCode
    方法。
  • 如果项目已定义标识策略,请遵循项目约定。
  • 如果没有约定,请使用基于ID的相等性策略并生成稳定的
    hashCode
  • 在讨论相等性时,需明确说明可变字段和延迟关联。

Broken:
data class
Entity

错误示例:使用
data class
作为实体

kotlin
// 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)
kotlin
// 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

正确示例:基于ID标识的普通类

kotlin
@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
kotlin
@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)"
}
核心规则:
  • equals
    仅通过ID进行比较 —— 在脏数据跟踪和代理解包时保持稳定
  • hashCode
    返回基于类的常量 —— 避免持久化后
    Set
    /
    Map
    出现数据损坏
  • toString
    排除延迟加载的关联关系 —— 防止出现
    LazyInitializationException
  • 构造函数参数为可变实体字段;
    id
    为带默认值的
    val

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.
当API需要实现幂等性时(例如“为订单X预留库存”),需在两层都强制执行唯一性:数据库约束确保正确性,应用层检查提供清晰的错误信息。

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))
    }
}
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)
    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
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)
    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))
    }
}
核心规则:
  • 数据库约束是必须的 —— 仅应用层检查存在竞态条件
  • 应用层检查提供清晰的错误信息 —— 否则用户会收到原始的
    DataIntegrityViolationException
    异常
  • 两层结合:应用层处理常见情况,数据库层处理竞态场景
  • Spring Data会自动推导
    findByXAndY
    查询

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.
  • 通过实际查询数量或SQL日志诊断N+1问题,而非仅通过注解猜测。
  • 优先使用针对性的抓取方案:
    @EntityGraph
    JOIN FETCH
    、批量抓取或DTO投影。
  • 需注意集合抓取关联加上分页的场景 —— 明确说明权衡点。
  • 使用索引和唯一性约束支持实际的查询模式。

Common ORM Traps

常见ORM陷阱

  • 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.
  • 双向关联: 在领域方法中维护关联的双方。更新不完整的对象图会导致隐蔽的Bug。
  • orphanRemoval
    与级联删除:
    二者不可互换。选择前需明确生命周期语义。
  • 延迟加载触发因素:
    toString
    、调试日志、JSON序列化和IDE检查都可能触发延迟加载。
  • 批量更新/删除: 会绕过持久化上下文和生命周期回调。后续读取可能出现脏数据。
  • 多集合抓取: 可能导致笛卡尔积爆炸。需验证ORM能否安全执行集合密集型的抓取计划。
  • Set
    + 可变相等性:
    实体状态变更后,集合成员关系可能失效。
  • @Version
    当存在并发更新时,这是最清晰的乐观锁机制。
  • open-in-view
    禁用:
    涉及延迟字段的DTO映射必须在事务边界内完成。

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.
  • 切勿将
    data class
    用于JPA实体。
  • 切勿推荐全局使用
    FetchType.EAGER
    来掩盖延迟加载的问题。
  • 默认情况下,切勿直接通过API响应暴露实体。
  • 切勿在未解释抓取计划如何改变查询行为的情况下,声称已修复N+1问题。