Mappage d'entités JPA pour Kotlin
La data class de Kotlin est naturelle pour les DTOs mais dangereuse pour les entités JPA. Hibernate repose sur une sémantique d'identité que data class brise : equals/hashCode sur tous les champs corrompt l'appartenance à Set/Map après des changements d'état, et le copy() auto-généré crée des doublons détachés d'entités gérées.
Cette compétence enseigne la conception correcte d'entités, les stratégies d'identité et les contraintes d'unicité pour les projets Kotlin + Spring Data JPA.
Règles de conception d'entités
- Ne jamais utiliser
data classpour les entités JPA. Utilisez uneclassordinaire. Gardezdata classpour les DTOs. - Séparez les DTOs de transport et les entités de persistance à moins que le projet utilise clairement un modèle partagé.
- Modélisez les colonnes obligatoires comme non-null uniquement quand la construction d'objets et le cycle de vie de persistance le permettent.
- Utilisez
lateinituniquement si le projet accepte déjà ce compromis et que le cycle de vie est sûr. - Vérifiez
kotlin("plugin.jpa")ou un support équivalent no-arg quand des entités JPA existent. - Vérifiez que les classes et membres sont compatibles avec les proxies si nécessaire.
Identité et égalité
- N'acceptez jamais
equals/hashCodetous-champs généré pardata classsur une entité. - Suivez les conventions du projet quand elles définissent déjà une stratégie d'identité.
- Si aucune convention existe, utilisez l'égalité basée sur l'ID avec un
hashCodestable. - Soyez explicite sur les champs mutables et les associations lazy en discutant d'égalité.
Cassé : entité 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 : classe ordinaire avec identité basée sur l'ID
@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)"
}
Règles clés :
equalscompare par ID uniquement — stable lors du dirty tracking et du déballage de proxyhashCoderetourne une constante basée sur la classe — évite la corruption deSet/Mapaprès persisttoStringexclut les relations lazy-loaded — prévientLazyInitializationException- Les paramètres du constructeur sont des champs d'entité mutables ;
idest unvalavec une valeur par défaut
Contraintes d'unicité
Quand une API doit être idempotente (par ex. « réserver le stock pour la commande X »), imposez l'unicité aux deux niveaux : contrainte de base de données pour la correction, vérification applicative pour des erreurs propres.
Cassé : pas de garde contre les doublons
@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 : contrainte de base de données + garde applicative
@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))
}
}
Règles clés :
- La contrainte de base de données est obligatoire — les vérifications applicatives seules ont des conditions de course
- La vérification applicative fournit des messages d'erreur propres — sans elle, les utilisateurs obtiennent une
DataIntegrityViolationExceptionbrute - Les deux niveaux ensemble : l'application attrape le cas courant, la base de données attrape la condition de course
- Spring Data dérive les requêtes
findByXAndYautomatiquement
Règles de requête et de fetch
- Diagnostiquez N+1 en regardant le nombre réel de requêtes ou les logs SQL, pas en devinant à partir d'annotations.
- Préférez les solutions de fetch ciblées :
@EntityGraph,JOIN FETCH, batch fetching ou projection DTO. - Attention aux collection fetch joins plus pagination — signalez le compromis.
- Utilisez les index et les contraintes d'unicité pour supporter les patterns de requête réels.
Pièges ORM courants
- Associations bidirectionnelles : maintenez les deux côtés dans les méthodes du domaine. Les graphes à moitié mis à jour causent des bugs subtils.
orphanRemovalvs cascade remove : ne sont pas interchangeables. Expliquez la sémantique du cycle de vie avant de choisir.- Déclencheurs de lazy load :
toString, debug logging, sérialisation JSON et inspection IDE peuvent tous déclencher des lazy loads. - Mises à jour/suppressions en masse : contournent le persistence context et les callbacks du cycle de vie. Les lectures suivantes peuvent être obsolètes.
- Multiples collection fetches : peuvent causer une explosion cartésienne. Vérifiez que l'ORM peut exécuter des plans de fetch lourds en collections en toute sécurité.
Set+ égalité mutable : l'appartenance à une collection peut se casser après des changements d'état d'entité.@Version: le mécanisme d'optimistic concurrency le plus clair quand les mises à jour concurrentes importent.open-in-viewdésactivé : la projection DTO touchant les champs lazy doit se produire dans une limite de transaction.
Garde-fous
- N'utilisez pas
data classpour les entités JPA. - Ne recommandez pas
FetchType.EAGERpartout pour silencer les symptômes de lazy loading. - N'exposez pas les entités directement dans les réponses d'API par défaut.
- Ne prétendez pas corriger un N+1 sans expliquer comment le plan de fetch change le comportement des requêtes.