kotlin-backend-jpa-entity-mapping について
Kotlin と Spring Data JPA を使う開発者向けに、エンティティ設計、識別性、一意制約を解説するスキルです。Hibernate の落とし穴と永続化時の対策に重点を置きます。
主な機能
- 01エンティティに data class を使わない
- 02安定した ID ベースの等価性
- 03DB とアプリで一意性を保証
- 04N+1 の診断と適切なフェッチ
- 05遅延ロードと関連のライフサイクル管理
活用シーン
- 01Kotlin エンティティの equals と hashCode の確認
- 02冪等操作の重複データ防止
- 03N+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 が壊してしまう ID ベースの同一性セマンティクスに依存しています。すべてのフィールドを対象とする equals/hashCode は、状態変更後に Set/Map のメンバーシップを破損させ、自動生成される copy() は管理対象エンティティのデタッチ済みの重複を作成します。
このスキルでは、Kotlin + Spring Data JPA プロジェクト向けに、適切なエンティティ設計、同一性の戦略、一意性制約について説明します。
エンティティ設計のルール
- JPA エンティティに
data classを使用しないでください。 通常のclassを使用してください。data classは DTO 用にします。 - プロジェクトで共有モデルを明確に採用している場合を除き、転送用 DTO と永続化エンティティは分けてください。
- オブジェクトの構築と永続化ライフサイクルに問題がない場合に限り、必須列を非 null としてモデル化してください。
- プロジェクトですでにそのトレードオフが許容されており、ライフサイクルにも問題がない場合に限り、
lateinitを使用してください。 - JPA エンティティが存在する場合は、
kotlin("plugin.jpa")または同等の no-arg サポートがあることを確認してください。 - 必要に応じて、クラスとメンバーがプロキシ化に対応していることを確認してください。
同一性と等価性
- エンティティに対して、
data classが生成する全フィールド対象のequals/hashCodeを決して使用しないでください。 - 既存の同一性戦略が定められている場合は、プロジェクトの規約に従ってください。
- 規約がない場合は、安定した
hashCodeを使用する ID ベースの等価性を採用してください。 - DB 生成 ID の場合は、未保存状態を
var id: Long? = nullとしてモデル化し、セッターをprotectedにしてください。センチネル値として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/Mapの破損を防ぎます。toStringには遅延ロードされる関連を含めません。LazyInitializationExceptionを防ぎます。- コンストラクターパラメーターは可変のエンティティフィールドです。DB 生成の
idは nullable とし、セッターはprotectedにします。
一意性制約
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クエリを自動的に導出します。
クエリとフェッチのルール
- N+1 は、アノテーションから推測するのではなく、実際のクエリ数や SQL ログを見て診断してください。
- 対象を絞ったフェッチ方法を優先してください。
@EntityGraph、JOIN FETCH、バッチフェッチ、DTO プロジェクションなどがあります。 - コレクションのフェッチ結合とページネーションの併用には注意し、トレードオフを説明してください。
- 実際のクエリパターンを支えるインデックスと一意性制約を使用してください。
よくある ORM の落とし穴
- 双方向関連: ドメインメソッドで両側を更新してください。片側だけ更新されたグラフは、見つけにくいバグの原因になります。
orphanRemovalと cascade remove: 互換ではありません。選択する前に、ライフサイクル上の意味を説明してください。- 遅延ロードのトリガー:
toString、デバッグログ、JSON シリアライズ、IDE のインスペクションはいずれも遅延ロードを引き起こす可能性があります。 - 一括更新/削除: 永続化コンテキストとライフサイクルコールバックを迂回します。その後の読み取り結果が古くなる場合があります。
- 複数の bag フェッチ: 直積爆発を引き起こす可能性があります。コレクションを多く含むフェッチプランを ORM が安全に実行できることを確認してください。
Set+ 可変な等価性: エンティティの状態変更後に、コレクション内のメンバーシップが壊れる可能性があります。@Version: 同時更新が問題となる場合に、最も明確な楽観的同時実行制御の仕組みです。open-in-viewが無効: 遅延フィールドに触れる DTO マッピングは、トランザクション境界内で行う必要があります。
ガードレール
- JPA エンティティに
data classを使用しないでください。 - 遅延読み込みの症状を抑えるために、あらゆる箇所で
FetchType.EAGERを推奨しないでください。 - デフォルトでエンティティを API レスポンスに直接公開しないでください。
- フェッチプランによってクエリの挙動がどのように変わるのかを説明せずに、N+1 が解決したと主張しないでください。
コメント
Markdown 対応