De String à collection : comment une évolution métier m’a fait découvrir @ElementCollection
Récemment, au cours d’une mission client, j’ai dû faire évoluer une des entités principales de leur application. Ce qui semblait au départ une simple modification fonctionnelle s’est avéré être une belle occasion de plonger un peu plus dans les subtilités de JPA et Hibernate.
Le besoin métier
L’application sur laquelle je travaille est développée en Kotlin avec Spring Boot. L’une de mes entités contenait un attribut sous forme de String, représentant une donnée fonctionnelle unique.
Puis un jour, une évolution métier est arrivée :
« On aimerait pouvoir renseigner plusieurs valeurs pour ce champ, pas juste une seule. »
Simple sur le papier. Mais côté code, cette petite phrase change tout : il ne s’agissait plus d’un String, mais d’une liste de valeurs à stocker en base.
Les premières pistes
Mon premier réflexe a été d’évaluer deux options possibles :
- Option 1 : passer le champ directement en collection.
Prenons cette entité de départ :
@Entity
@Table("product")
class Product(
@Id @GeneratedValue
val id: Long,
val name: String,
@Column(name = "tag")
val tag: String? = null,
)Cette option consiste à transformer le champ en MutableList<String> puis faire passer un script Flyway (PostgreSQL) pour renommer ma colonne, changer son type de varchar à varchar[] et migrer les données existantes. Hibernate s’occupe du mapping lui-même.
Cela donnerait :
@Entity
@Table("product")
class Product(
@Id @GeneratedValue
val id: Long,
val name: String,
@Column(name = "tags")
val tags: MutableList<String>? = mutableListOf(),
)-- Rename column
ALTER TABLE product
RENAME COLUMN tag TO tags;
-- Change column type and migrate existing data
ALTER TABLE product
ALTER COLUMN tags TYPE varchar[]
USING CASE
WHEN tags IS NULL THEN NULL
ELSE ARRAY [tags]
END;Pour un Sac de Luxe en cuir noir et une Montre en acier et en argent, en base de données on a alors :

Cela aurait fonctionné et impliquait une migration simple des anciennes données. Mais cette approche présente deux limites : d’une part, elle reste rigide si le besoin métier évolue et que chaque valeur doit devenir une entité à part entière avec plus de détails ; d’autre part, si la collection venait à contenir plus d’une cinquantaine de tags, la structure ne serait plus adaptée.
- Option 2 : créer une nouvelle entité et passer sur une relation
@OneToMany.
@Entity
@Table(name = "product")
class Product(
@Id
@GeneratedValue
val id: Long,
val name: String,
@OneToMany(
mappedBy = "product",
cascade = [CascadeType.ALL],
)
val tags: MutableList<ProductTag>? = mutableListOf(),
)C’est une solution qui respecte parfaitement le modèle relationnel, mais dans mon cas, elle paraissait disproportionnée : elle nécessite la création d’une nouvelle entité ProductTag qui n’aurait eu qu’un champ métier name.
@Entity
@Table(name = "product_tag")
class ProductTag(
@Id
@GeneratedValue
val id: Long,
val name: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
val product: Product,
)C’est à ce moment que mon tech lead m’a posé exactement cette question :
“Tu comptes faire un array de String, ou un OneToMany ?”
N’étant pas encore satisfaite, c’est en creusant que j’ai découvert une troisième option : l’utilisation de @ElementCollection avec @CollectionTable.
@ElementCollection, qu’est-ce que c’est ?
@ElementCollection permet de mapper une collection de types simples (comme String, Integer, Enum, etc.) ou d’objets embeddables dans une table séparée, sans devoir créer une entité à part entière.
Concrètement, JPA crée automatiquement une table de collection liée à l’entité principale par une clé étrangère.
Reprenons notre cas en y implémentant la nouvelle annotation :
@Entity
class Product(
@Id @GeneratedValue
val id: Long,
val name: String,
@ElementCollection
@CollectionTable(
name = "product_tags",
joinColumns = [JoinColumn(name = "product_id")]
)
@Column(name = "tag")
val tags: MutableList<String>? = mutableListOf()
)Ici, JPA va générer une table product_tags avec deux colonnes :
product_id(la clé étrangère versProduct)tag(la valeur de chaque élément de la liste)
Exemple concret de contenu
En repartant de ces produits :

La table product_tags générée par JPA ressemblerait alors à ceci :

Chaque élément de la liste est stocké sur une ligne séparée, avec une référence vers l’entité principale via product_id.
NB : On n’oublie pas d’ajouter des indexes si on fait beaucoup de recherche sur notre collection.
Le résultat
Après la migration, tout fonctionnait bien :
- Le modèle simple et lisible,
- le mapping SQL géré par Hibernate,
- aucune entité supplémentaire nécessaire.
Cette approche m’a permis de répondre rapidement au besoin métier, tout en gardant un schéma de données cohérent.
Troubleshoot : les LazyInitializationException
Tout n’a pas été parfait dès le premier essai. Lors de la sérialisation JSON de mes entités, j’ai eu droit à la fameuse exception Hibernate : LazyInitializationException.
En effet, les collections marquées avec @ElementCollection sont lazy par défaut. Autrement dit, elles ne sont pas chargées tant qu’on n’y accède pas dans un contexte de session active. Et si on tente d’y accéder dans un autre contexte (par exemple via un contrôleur REST), Hibernate lève une exception.
Il y a plein de solutions possibles pour gérer ces exceptions. De mon côté, j’ai ajouté ce champ dans un @NamedEntityGraph, qui existait déjà dans mon entité, afin de contrôler le chargement anticipé de la collection lorsque je récupère mon entité principale.
@Entity
@Table(name = "product")
@NamedEntityGraph(
name = "product_entity_graph",
attributeNodes =[NamedAttributeNode("tags")]
)
class Product(
@Id @GeneratedValue
val id: Long,
val name: String,
@ElementCollection
@CollectionTable(
name = "product_tags",
joinColumns = [JoinColumn(name = "product_id")]
)
@Column(name = "tag")
val tags: MutableList<String> = mutableListOf()
)Ainsi, je pouvais éviter le lazy quand je savais que j’allais avoir besoin de ces données.
Évolutivité vers un @OneToMany
Si le besoin métier venait à évoluer et que chaque élément de la collection devait devenir une entité à part entière avec plus d’attributs ou des relations supplémentaires, la transition depuis @ElementCollection resterait assez simple :
- La table de collection peut être transformée en table d’entité.
- L’entité principale passe de
@ElementCollectionà@OneToMany. - Les données existantes peuvent être migrées vers la nouvelle structure.
Cette flexibilité permet de dire que @ElementCollection est une solution qui préserve la maintenabilité et anticipe des évolutions futures.
Conclusion
Ce changement m’a rappelé une chose :
Chaque petite évolution fonctionnelle est une opportunité d’apprendre quelque chose de nouveau techniquement.
Et parfois, ce sont justement ces “petits” défis qui nous font progresser le plus.