kotlin-backend-jpa-entity-mapping

Par kotlin · kotlin-agent-skills

Modélisez correctement le code de persistance Kotlin pour Spring Data JPA et Hibernate. Couvre la conception des entités, l'identité et l'égalité, les contraintes d'unicité, les relations, les plans de fetch et les pièges ORM (Object-Relational Mapping) spécifiques à Kotlin. À utiliser lors de la création ou de la révision d'entités JPA (Java Persistence API), du diagnostic de problèmes N+1 ou de `LazyInitializationException`, de la définition d'index et de règles d'unicité, ou de la prévention de bugs propres à Kotlin tels que les entités data class et les implémentations `equals`/`hashCode` défectueuses.

npx skills add https://github.com/kotlin/kotlin-agent-skills --skill kotlin-backend-jpa-entity-mapping

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 class pour les entités JPA. Utilisez une class ordinaire. Gardez data class pour 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 lateinit uniquement 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/hashCode tous-champs généré par data class sur 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 hashCode stable.
  • 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 :

  • equals compare par ID uniquement — stable lors du dirty tracking et du déballage de proxy
  • hashCode retourne une constante basée sur la classe — évite la corruption de Set/Map après persist
  • toString exclut les relations lazy-loaded — prévient LazyInitializationException
  • Les paramètres du constructeur sont des champs d'entité mutables ; id est un val avec 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 DataIntegrityViolationException brute
  • 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 findByXAndY automatiquement

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.
  • orphanRemoval vs 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-view désactivé : la projection DTO touchant les champs lazy doit se produire dans une limite de transaction.

Garde-fous

  • N'utilisez pas data class pour les entités JPA.
  • Ne recommandez pas FetchType.EAGER partout 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.

Skills similaires