关于 kotlin-backend-jpa-entity-mapping
面向 Kotlin + Spring Data JPA 项目的开发者,提供实体建模、身份相等性和唯一性约束方面的指导,重点说明 Hibernate 常见陷阱及安全做法。
主要功能
- 01避免用 data class 定义实体
- 02采用稳定的 ID 相等性策略
- 03结合数据库与应用层实施唯一性约束
- 04诊断 N+1 并选择针对性抓取方案
- 05处理延迟加载与关联生命周期
适用场景
- 01审查 Kotlin JPA 实体的 equals 与 hashCode
- 02为幂等操作设计重复数据防护
- 03分析 N+1 查询与抓取计划
- 04避免序列化或日志触发延迟加载
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ kotlin/ kotlin-agent-skills --skill kotlin-backend-jpa-entity-mapping安装后在 Claude Code 中输入/kotlin-backend-jpa-entity-mapping确认已加载。
详细介绍
Kotlin 的 JPA 实体映射
Kotlin 的 data class 很适合用作 DTO,但用于 JPA 实体则有风险。Hibernate 依赖 data class 会破坏的身份语义:基于所有字段实现的 equals/hashCode 会在状态变化后破坏 Set/Map 成员关系,而自动生成的 copy() 会创建受管实体的游离副本。
本技能将介绍 Kotlin + Spring Data JPA 项目的正确实体设计、身份策略和唯一性约束。
实体设计规则
- 切勿将
data class用作 JPA 实体。 使用普通class。将data class用于 DTO。 - 除非项目明确使用共享模型,否则应将传输 DTO 与持久化实体分开。
- 只有在对象构造和持久化生命周期足够安全时,才将必填列建模为非空。
- 只有当项目已接受这种权衡且生命周期安全时,才使用
lateinit。 - 存在 JPA 实体时,确认已启用
kotlin("plugin.jpa")或同等的无参构造函数支持。 - 在需要时,确认类和成员与代理机制兼容。
身份与相等性
- 切勿接受
data class在实体上生成的基于所有字段的equals/hashCode。 - 如果项目已有身份策略约定,请遵循该约定。
- 如果没有约定,请使用基于 ID 的相等性,并采用稳定的
hashCode。 - 对于由数据库生成的 ID,使用可空的
var id: Long? = null表示未保存状态,并设置protected set;不要用0L作为哨兵值。 - 讨论相等性时,明确说明可变字段和延迟加载关联的影响。
错误示例:data class 实体
// 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)
正确示例:使用基于 ID 的身份标识的普通类
@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)"
}
关键规则:
equals仅按 ID 进行比较——在脏跟踪和代理解包期间保持稳定hashCode返回基于类的常量——避免持久化后破坏Set/MaptoString排除延迟加载的关联——避免触发LazyInitializationException- 构造函数参数是可变的实体字段;由数据库生成的
id可空,并带有受保护的 setter
唯一性约束
当 API 必须具备幂等性(例如“为订单 X 预留库存”)时,应在两个层面强制唯一性:数据库约束确保正确性,应用层检查则提供清晰的错误信息。
错误示例:没有重复项保护
@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))
}
}
正确示例:数据库约束 + 应用层保护
@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<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查询
查询与抓取规则
- 通过实际查询数量或 SQL 日志诊断 N+1 问题,不要根据注解猜测。
- 优先选择有针对性的抓取方案:
@EntityGraph、JOIN FETCH、批量抓取或 DTO 投影。 - 使用集合抓取连接时要谨慎处理分页——应说明其中的权衡。
- 使用索引和唯一性约束来支持实际查询模式。
ORM 常见陷阱
- 双向关联: 在领域方法中维护两端。只更新一端会导致微妙的错误。
orphanRemoval与级联删除: 两者不可互换。选择前应说明其生命周期语义。- 触发延迟加载:
toString、调试日志、JSON 序列化和 IDE 检查都可能触发延迟加载。 - 批量更新/删除: 会绕过持久化上下文和生命周期回调。后续读取可能得到过期数据。
- 多个 bag 抓取: 可能导致笛卡尔积爆炸。应验证 ORM 能否安全执行包含大量集合的抓取计划。
Set+ 可变相等性: 实体状态变化后,集合成员关系可能失效。@Version: 在并发更新很重要时,这是最清晰的乐观并发控制机制。- 禁用
open-in-view: 在 DTO 映射过程中访问延迟加载字段时,必须处于事务边界内。
防护措施
- 不要将
data class用于 JPA 实体。 - 不要为了消除延迟加载问题而到处推荐
FetchType.EAGER。 - 默认不要在 API 响应中直接暴露实体。
- 声称解决了 N+1 问题时,必须说明获取计划如何改变查询行为。
评论
支持 Markdown