CodeGym /Cours /Hibernate deep-dive /Mapping Money et Address via Embeddable

Mapping Money et Address via Embeddable

Hibernate deep-dive
Niveau 14, Leçon 1
Disponible

1. @Embeddable : une valeur métier au lieu de primitifs

Nous avons déjà séparé les objets ayant leur propre cycle de vie des valeurs qui vivent à l’intérieur du possesseur. Il faut maintenant comprendre comment stocker ces valeurs sans multiplier les tables, les id et les relations là où ils ne sont pas nécessaires.

Si vous avez déjà vu dans le code quelque chose comme priceAmount et priceCurrency disséminés dans cinq entités, vous connaissez cette sensation : tout « fonctionne », mais le modèle ressemble à un assemblage de LEGO dans le noir. Aujourd’hui, nous abordons justement la façon de rendre le modèle plus clair : ne pas ajouter « une autre entité », mais extraire une valeur métier unique et la mapper proprement.

Le problème des champs primitifs ne réside généralement pas dans « le nombre de colonnes ». Il vient plutôt du fait que le sens disparaît du code. BigDecimal seul n’est qu’un nombre. String currency seul n’est qu’une chaîne. Mais « l’argent » est un nombre dans une devise spécifique, et ces deux champs doivent presque toujours vivre ensemble et être modifiés comme une seule unité. S’ils vivent séparément, dans un vrai projet vous rencontrerez inévitablement des situations où le montant a été mis à jour mais pas la devise (ou l’inverse). Et ce n’est pas une « erreur d’Hibernate », c’est le prix naturel d’un modèle dont le sens est dispersé dans des primitifs.

Pour l’adresse, l’histoire est similaire, mais plus longue. L’adresse n’est pas simplement un ensemble de chaînes « ville/rue/numéro ». C’est une valeur que l’on transmet, stocke et modifie dans son ensemble. Parfois l’adresse est « l’adresse actuelle du client », parfois elle est « l’instantané de l’adresse de livraison de la commande au moment de la validation ». Du point de vue métier, ce sont des sens différents, mais techniquement, il est pratique de les décrire avec le même type Address.

Et là se pose une bonne question : si Money et Address sont des value object, comment les stocker en base de données sans les transformer en tables séparées avec id, @OneToMany, cascades et la douleur éternelle du « pourquoi encore une jointure supplémentaire » ? La réponse est @Embeddable.

2. @Embeddable et @Embedded : aplatissement dans la table

Lorsque vous voyez @Embeddable pour la première fois, vous pourriez penser : « Ah, c’est comme une entité, mais en plus petit. » Non. C’est plutôt un « morceau d’entité » qu’Hibernate sait stocker avec son propriétaire dans la même table. Et c’est l’idée clé : par défaut, un embeddable n’a pas sa propre ligne en base de données et n’a pas son propre cycle de vie.

En termes JPA, nous décrivons une classe‑valeur avec l’annotation @Embeddable, puis nous l’intégrons dans une entité avec @Embedded. Voici à quoi cela ressemble :

import jakarta.persistence.Embeddable;

@Embeddable
public class Money {
    // champs qui seront stockés dans la table du propriétaire
}

Puis dans l’entité :

import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;

@Entity
public class Product {
    @Embedded
    private Money price;
}

Il est utile d’avoir en tête une image simple de l’aplatissement (flattening) :

Product (entité)
└── price: Money (embeddable)
    ├── amount
    └── currency

↓ dans la table product, cela devient simplement des colonnes

product
├── id
├── ...
├── price_amount
└── price_currency

Il n’y a pas de table money, pas de money_id, pas de JOIN. Hibernate lit et écrit simplement les colonnes du propriétaire.

Pour ne pas confondre @Embeddable avec une entité, comparons dans un tableau :

Ce qu’on compare
@Entity
@Embeddable
Possède @Id Oui Non
Table séparée Oui Non (par défaut)
Chargeable séparément (find) Oui Non
Cycle de vie propre Oui Non, vit chez le propriétaire
A un impact SQL comme un JOIN séparé Souvent oui Non (tout dans une ligne)

Et un autre point important : si une entité est un « personnage » qui peut vivre indépendamment, un embeddable est une « caractéristique du personnage ». Elle n’a pas de sens sans son propriétaire. De l’argent sans produit, ce n’est que de « l’argent quelconque ». Une adresse sans « de quoi est‑ce l’adresse » n’est pas très utile non plus.

3. Money comme @Embeddable dans le projet

Nous allons maintenant faire de Money une partie du module commun du projet, afin qu’il puisse être utilisé dans le catalogue et les commandes. Dans la structure du projet transverse, nous avons un paquet com.example.commerce.common.jpa.embeddable pour ce genre de choses. C’est là que nous placerons Money.

Commençons par une version très simple : montant (BigDecimal) et devise (String). Ajoutons immédiatement un constructeur protégé sans argument – il est nécessaire pour qu’Hibernate crée l’objet lors du chargement depuis la base de données. Oui, nous écrivons encore un constructeur « étrange » que nous n’appelons jamais manuellement. Bienvenue dans l’ORM : parfois il demande de faire quelque chose « pour lui », comme un chat qui demande d’ouvrir la porte pour tout de suite changer d’avis.

package com.example.commerce.common.jpa.embeddable;

import jakarta.persistence.Embeddable;
import java.math.BigDecimal;

@Embeddable
public class Money {

    // Ces champs seront "aplatis" dans les colonnes de la table propriétaire (par exemple product.price_amount et product.price_currency)
    private BigDecimal amount;
    private String currency;

    // Nécessaire pour Hibernate/JPA pour créer l’objet lors de la lecture en base (via reflection)
    protected Money() { }

    public Money(BigDecimal amount, String currency) {
        // On peut ajouter ici des invariants métier (par exemple amount >= 0, devise selon ISO, etc.)
        this.amount = amount;
        this.currency = currency;
    }
}

Si vous vous demandez : « Et où est getAmount() ? » – il sera là. Je ne veux pas transformer la leçon en exposition de « 1000 lignes de getters ». Mais l’idée est la suivante : un embeddable est une classe Java ordinaire, donc son API peut être correcte et expressive. Et oui, pour un value object, l’égalité se fait généralement sur les valeurs des champs, pas sur la référence. Nous en discuterons en chemin, mais même intuitivement c’est logique : 10 USD et 10 USD sont la même valeur, même s’il s’agit de deux objets différents en mémoire.

Une contrainte importante à énoncer clairement : Money n’aura pas de @Id. Il ne peut pas être « cherché dans un repository ». Il ne peut pas être « supprimé séparément ». Il n’existe qu’à l’intérieur de son propriétaire. C’est pourquoi nous rendons Money simple, compact et très clair.

4. Product.price : Money dans l’entité et le schéma

Maintenant, prenons l’entité centrale du catalogue – Product – et faisons du prix une valeur métier normale, plutôt qu’une paire de primitifs. Dans nos exemples, nous ne montrerons que les champs nécessaires pour ne pas nous noyer dans « et encore 15 colonnes que vous avez déjà ».

Dans Product, ajoutons le champ price avec l’annotation @Embedded :

package com.example.commerce.catalog.entity;

import com.example.commerce.common.jpa.embeddable.Money;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class Product {

    @Id
    private Long id;

    // Intégration du value object Money dans la table Product (pas de JOIN – tout réside dans la ligne product)
    @Embedded
    private Money price;

    // Hibernate nécessite un constructeur sans argument pour créer l’entité lors du chargement
    protected Product() { }
}

Du point de vue du code Java, cela semble presque trop simple : on ajoute un champ et c’est tout. Mais au niveau du schéma, il y a un aspect pratique : les noms exacts des colonnes dépendent de la stratégie de nommage et des surcharges explicites. Ci‑dessous, je prends un schéma classique price_amount/price_currency ; si ces noms sont importants pour les migrations et les logs SQL, il vaut mieux les fixer explicitement plutôt que de compter sur le nommage implicite.

Imaginons qu’auparavant vous aviez price_amount et price_currency comme colonnes séparées, ou que vous les introduisez maintenant. Dans la migration (par exemple Vx__product_money.sql), cela ressemblerait à ceci :

ALTER TABLE product
    -- Colonnes pour les champs de l’embeddable Money
    ADD COLUMN price_amount numeric(19, 2),
    ADD COLUMN price_currency varchar(3);

Si vous avez déjà des données et souhaitez rendre ces colonnes NOT NULL, la migration devra être un peu plus prudente (avec une valeur par défaut, mise à jour des lignes existantes, etc.). Mais l’idée à ce stade n’est pas l’art de la migration, mais de comprendre que l’embeddable ne crée pas de table, il exige simplement que la table du propriétaire contienne les colonnes correspondant à ses champs.

Pour lier visuellement « modèle Java » et « modèle base de données », voici un petit tableau de correspondance :

Java Table product
product.price.amount price_amount
product.price.currency price_currency

Et le plus agréable : lorsque vous chargez Product, Hibernate fait un seul SELECT sur product et récupère tous les champs de Money en même temps que les autres colonnes. Aucune requête supplémentaire ni jointure. Du point de vue SQL, cela ressemble schématiquement à ceci :

select
    p.id,
    p.price_amount,
    p.price_currency
from product p
where p.id = ?

Autrement dit, Money n’est pas « chargé à part ». Il n’est pas « lazy ». Il fait simplement partie de la ligne du produit, comme name ou sku. C’est à cet endroit que l’ORM fait vraiment ce qu’on attend de lui : aider à travailler avec le modèle sans ajouter de complexité superflue dans la base de données.

5. Address comme @Embeddable : une classe, plusieurs sens

Avec l’adresse, c’est un peu plus intimidant à cause du nombre de champs. Quand il y a beaucoup de champs, le débutant est tenté soit de créer une « table d’adresses » et de tout lier, soit de laisser tomber et de garder 8 colonnes de type chaîne dans l’entité. Nous allons essayer une troisième voie : extraire l’adresse comme value object, mais la stocker dans la table du propriétaire – exactement comme Money.

Mettons Address également dans com.example.commerce.common.jpa.embeddable. Dans le cahier des charges du projet, l’adresse comprend le pays, la ville, le code postal, la rue, le numéro, l’appartement. Dans l’exemple, je le montre de manière compacte, mais l’idée reste :

package com.example.commerce.common.jpa.embeddable;

import jakarta.persistence.Embeddable;

@Embeddable
public class Address {

    // Ces champs sont "intégrés" dans la table du propriétaire (customer_address ou purchase_order)
    private String country;
    private String city;
    private String street;

    // Nécessaire pour JPA pour reconstruire l’objet à partir des données de la ligne de table
    protected Address() { }

    public Address(String country, String city, String street) {
        // Ici vivent généralement les validations métier : chaînes vides, format, etc.
        this.country = country;
        this.city = city;
        this.street = street;
    }
}

Pourquoi est‑ce un value object et non une entité ? Parce que l’adresse elle‑même n’a pas de « destin séparé » dans notre modèle. Elle décrit soit « où livrer la commande », soit « où habite le client ». Nous ne recherchons pas une adresse « par son id », nous ne faisons pas un « CRUD d’adresses » en tant que produit indépendant, nous ne voulons pas de transaction séparée « mettre à jour l’adresse comme une entité ». L’adresse fait partie de l’état du propriétaire.

Un autre détail important : la même classe Address peut être utilisée à deux endroits avec des sens différents. Chez CustomerAddress, c’est « l’adresse actuelle du client », et chez PurchaseOrder, c’est « l’instantané de l’adresse de livraison ». Techniquement, le type est le même, mais la sémantique est déterminée par le champ du propriétaire. Et c’est normal : le type est la forme, et le champ est le rôle.

6. Address dans CustomerAddress et PurchaseOrder

Intégrons maintenant Address dans deux entités déjà présentes dans notre projet : CustomerAddress et PurchaseOrder. C’est précisément l’endroit où l’étudiant tombe souvent dans la confusion : « Si la classe est la même, alors c’est la même valeur ? » Non. Ce sont deux valeurs différentes, simplement du même type.

CustomerAddress

package com.example.commerce.customer.entity;

import com.example.commerce.common.jpa.embeddable.Address;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class CustomerAddress {

    @Id
    private Long id;

    // Adresse actuelle du client : stockée dans la même ligne customer_address
    @Embedded
    private Address address;

    // Constructeur JPA
    protected CustomerAddress() { }
}

Maintenant, dans la table customer_address apparaîtront des colonnes pour les champs de l’adresse. Selon la stratégie de nommage, cela ressemble souvent à address_country, address_city, address_street ; si les noms exacts sont importants, il vaut mieux les fixer explicitement via @AttributeOverride. La migration ressemblera à ceci :

ALTER TABLE customer_address
    -- Colonnes pour l’Address intégré (préfixe address_)
    ADD COLUMN address_country varchar(100),
    ADD COLUMN address_city varchar(100),
    ADD COLUMN address_street varchar(200);

PurchaseOrder

package com.example.commerce.orders.entity;

import com.example.commerce.common.jpa.embeddable.Address;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class PurchaseOrder {

    @Id
    private Long id;

    // Adresse de livraison comme "instantané" au moment de la validation de la commande (aussi embedded)
    @Embedded
    private Address deliveryAddress;

    // Constructeur JPA
    protected PurchaseOrder() { }
}

Dans la table purchase_order apparaîtront des colonnes comme delivery_address_country, delivery_address_city, delivery_address_street. Si le nommage exact est important pour le schéma et les logs SQL, il vaut mieux le fixer explicitement plutôt que de se fier uniquement aux règles implicites. Migration schématique :

ALTER TABLE purchase_order
    -- Colonnes pour l’Address intégré (préfixe delivery_address_)
    ADD COLUMN delivery_address_country varchar(100),
    ADD COLUMN delivery_address_city varchar(100),
    ADD COLUMN delivery_address_street varchar(200);

Au niveau SQL, tout est parfaitement honnête : les tables ont été étendues avec des colonnes, mais aucune nouvelle table, clé étrangère ou jointure n’est apparue. En termes de vitesse et de prévisibilité de lecture, c’est généralement très avantageux, surtout quand l’adresse est nécessaire « toujours avec la commande » plutôt que par une requête séparée.

Pour bien mémoriser « un type – des rôles différents », imaginez le schéma ainsi :

CustomerAddress (entité)
└── address: Address              // adresse actuelle du client

PurchaseOrder (entité)
└── deliveryAddress: Address      // instantané de l’adresse de livraison pour la commande

Ce sont des champs différents et des ensembles de colonnes différents dans des tables différentes, même si la classe Java est la même. En ORM, c’est une situation tout à fait normale : un même type embeddable peut participer à différentes entités.

7. Comportement de l’embeddable dans le code et les requêtes

À ce stade, il est important de fixer quelques propriétés « strictes » de l’embeddable, pour ne pas construire d’attentes qu’Hibernate n’est pas obligé de satisfaire.

Premièrement, l’embeddable fait partie de l’état de l’entité. Cela signifie qu’il participe au chargement et à la sauvegarde du propriétaire. Si vous sauvegardez Product, vous sauvegardez aussi price. Si vous chargez PurchaseOrder, vous chargez aussi deliveryAddress (car ce sont les mêmes colonnes de la même ligne). Il n’existe pas de persist() séparé pour Money ou Address – et c’est une bonne chose : moins de façons de produire accidentellement du code « à moitié correct ».

Deuxièmement, un embeddable ne peut pas être chargé paresseusement de la même manière qu’une relation @ManyToOne. Si la colonne est dans la même table, elle est de toute façon dans ce même SELECT. Parfois, cela est perçu comme un inconvénient (« je ne peux pas mettre en lazy ! »), mais en pratique c’est un avantage : l’adresse et l’argent sont généralement des données de petite taille, et il est préférable de les avoir immédiatement plutôt que de faire des allers‑retours supplémentaires vers la base de données. @Embeddable concerne presque toujours des « données petites et toujours nécessaires ensemble ».

Troisièmement, on peut écrire des requêtes sur les champs d’un embeddable. C’est pratique et lisible. Par exemple, si vous voulez trouver tous les produits dans une devise donnée, vous pouvez faire référence à price.currency directement en JPQL :

package com.example.commerce.catalog.repository;

import com.example.commerce.catalog.entity.Product;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;

public interface ProductRepository extends Repository<Product, Long> {

    // JPQL accède au champ imbriqué de l’embeddable via un point (price.currency)
    // En SQL, cela se traduit par une condition sur la colonne price_currency
    @Query("select p from Product p where p.price.currency = :currency")
    List<Product> findByCurrency(String currency);
}

Et cela sera traduit en une condition SQL sur la colonne price_currency. Pas de magie : c’est simplement le chemin normal « via le point ».

Enfin, et c’est important : un embeddable ne doit pas devenir un « dépotoir de champs ». Si vous ressentez l’envie d’ajouter dans Address encore « tout ce qui vous passe par la tête » (par exemple phone, deliveryComment, geoLat, geoLng, « et aussi le code du digicode »), arrêtez‑vous et demandez‑vous : est‑ce vraiment une valeur unique ? Ou bien êtes‑vous en train de coller différents sens dans une même boîte par commodité ? Un embeddable est bon tant qu’il reste une valeur métier cohérente et ne se transforme pas en une boîte librement mutable de détails aléatoires.

8. Erreurs typiques lors du mapping @Embeddable

Erreur n°1 : s’attendre à ce que @Embeddable crée une table séparée ou vive comme une entité.
C’est un « transfert d’habitudes » fréquent : puisque l’annotation existe, alors « l’ORM fera tout tout seul ». Un embeddable ne crée pas d’entité séparée ni de table séparée. Il est aplati dans les colonnes du propriétaire. Si vous avez besoin d’un cycle de vie séparé, de requêtes séparées et d’une « existence » indépendante – c’est une entité, pas un embeddable.

Erreur n°2 : oublier le constructeur sans argument et commencer à se battre avec Hibernate.
Hibernate crée l’objet par reflection. Si Money ou Address n’a pas de constructeur protected/package-private sans argument, le chargement depuis la base de données peut se terminer par une erreur très laide à l’exécution. L’ironie est que vous avez « bien fait » du point de vue OOP pur, mais l’outil ORM a quand même besoin d’un endroit où poser le pied.

Erreur n°3 : transformer l’embeddable en « dépotoir de tout ce qu’on n’ose pas jeter ».
Un embeddable doit être une valeur cohérente. Si vous y mettez des champs non liés « pour faire joli dans le modèle », vous obtenez un value object sans sens de valeur. Cela finit généralement par rendre les requêtes difficiles à écrire, compliqué de comprendre pourquoi tel champ a été mis à jour, et on ne sait plus vraiment ce qu’est cet objet dans le domaine.

Erreur n°4 : essayer de « sauvegarder séparément » ou « supprimer séparément » Money/Address.
Si la main vous démange de créer un MoneyRepository ou d’appeler entityManager.persist(money), c’est le signe que vous confondez value object et entité dans votre tête. Un embeddable n’est sauvegardé que comme partie du propriétaire. Il n’y a pas d’opération de sauvegarde séparée pour lui, et c’est bien ainsi : sinon le value object obtiendrait soudainement un cycle de vie indépendant, et cesserait donc d’être un value object.

Erreur n°5 : se perdre dans le sens d’une même classe embeddable utilisée à différents endroits.
Address dans CustomerAddress et Address dans PurchaseOrder – c’est la même classe, mais des rôles différents. Si vous commencez à penser qu’il s’agit de « la même valeur » et essayez de réutiliser un même objet d’adresse entre les entités, le modèle deviendra fragile et peu clair. Un type ne signifie pas un seul sens ; le sens est donné par le champ du propriétaire et son contexte métier.

1
Mission
Hibernate deep-dive, niveau 14, leçon 1
Bloqué
Tarif d'abonnement avec Money intégré
Tarif d'abonnement avec Money intégré
1
Mission
Hibernate deep-dive, niveau 14, leçon 1
Bloqué
Point de retrait avec adresse intégrée
Point de retrait avec adresse intégrée
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION