1. Séparation de l'entity et du DTO
Presque chaque débutant propose à un moment donné une optimisation géniale : « Ne multiplions pas les classes. Voici Product — on va le sauvegarder en base, le transmettre entre les couches, et le renvoyer à l'extérieur. Une classe, un amour ». Cela sonne bien, comme « un bouton — déploiement en prod ». Mais c'est précisément cette « simplification » qui transforme souvent le projet en un amas fragile de couches, où tout changement casse tout d'un coup.
Pour voir le problème, imaginons deux besoins qui semblent similaires mais sont en réalité différents.
Le premier besoin : nous devons stocker le produit comme un enregistrement persistant de données. Cela concerne la table, les colonnes, les contraintes, l'identité, les règles JPA, le cycle de vie de l'objet par rapport au contexte de persistance. Le deuxième besoin : nous devons transmettre les données sur le produit ailleurs — par exemple, vers une autre couche de l'application, vers un test, un journal, un futur adaptateur web ou simplement « en réponse » à une opération. Il s'agit déjà de la forme des données pour la transmission : quels champs sont nécessaires, sous quelle forme, avec quelles garanties (immutable/mutable), et ce qui peut être affiché en toute sécurité.
Si l'on mélange ces deux besoins dans une même classe, on obtient une classe qui doit simultanément satisfaire les exigences de JPA et les exigences de transmission de données. Et ces exigences… disons, ne sont pas très amies. C'est comme essayer de fabriquer un outil qui est à la fois un marteau et un microscope. On peut planter des clous avec un microscope, mais on peut aussi s'étonner des fissures dans les lentilles.
2. Entity : modèle de stockage, pas DTO
Une entity en JPA n'est pas simplement « une classe que l'on peut sauvegarder ». C'est un objet qui vit selon les règles du modèle de persistance : il a une identité, il est lié au schéma de la base de données, il obéit aux contraintes de l'ORM et peut se trouver dans différents états (on se souvient de transient/managed/detached/removed). Par conséquent, l'entity a des exigences et des « surprises » qui sont très inconfortables à traîner dans toute l'application si on la considère comme un simple transporteur de données.
Pour ressentir la différence, il suffit de comparer comment nous pensons à Product en tant qu'entity et en tant que DTO.
Entity est « un objet représentant un enregistrement en base de données ». Dans celui-ci, ce qui nous intéresse, c'est ce qui est stocké et comment, quels champs sont obligatoires, lesquels sont uniques, quels types de colonnes nous choisissons, à quoi ressemble l'identité et quelle clé métier est stable. DTO est un « message » que nous transmettons : il n'est pas obligé de répéter le schéma de la base de données et il n'est pas obligé d'être apte à la sauvegarde.
Voici un petit extrait (non complet) de l'entity Product, juste pour rappeler son caractère :
package com.example.shopdatajpa.catalog.entity;
import java.math.BigDecimal;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity // Marquons la classe comme JPA-entity : c'est déjà un « modèle de stockage », pas simplement un POJO.
public class Product {
@Id // Identité de l'entité en termes ORM/BDD.
private Long id;
// Les champs ci-dessous sont ceux qui sont généralement mappés sur les colonnes de la table.
// Les getters/setters et les autres annotations sont volontairement omis : c'est un fragment pour démontrer le rôle.
private String sku;
private String name;
private BigDecimal price;
}
Même dans un fragment aussi court, on voit : l'entity « se souvient » de JPA (@Entity, @Id). Et ce ne sont pas des autocollants décoratifs. C'est un signal : « avec moi, l'ORM travaille, j'ai des règles ».
Maintenant, imaginez que vous commencez à utiliser cette même classe comme DTO et que vous la traînez « à l'extérieur » (vers d'autres couches). À un moment donné, vous voudrez ajouter un champ « affichage joli du prix », par exemple priceLabel = "1999.00 USD" ou « courte description pour le catalogue ». Et c'est là que commence le lent mais sûr effacement des frontières : des champs qui ne concernent pas le stockage mais la présentation commencent à apparaître dans l'entity. Cela semble mineur, mais c'est précisément de ces petites choses que naît le « monstre DTO », qui est à la fois une mauvaise entity et un mauvais DTO.
Il est important de se rappeler séparément : nous savons déjà que les entités peuvent être dans différents états. Le DTO en tant qu'idée « simplement des données » n'a pas d'état managed/detached. Il est soit présent, soit absent. Alors qu'une entity peut être « sous surveillance » de l'ORM ou non. Si vous donnez une entity là où on n'y pense pas, vous augmentez considérablement les chances d'obtenir des effets inattendus : l'objet commence à être modifié « comme ça », sans comprendre que ce n'est peut-être pas un simple objet, mais une partie de la mécanique de persistance.
3. DTO : données de transport et record
Un DTO (Data Transfer Object) est un honnête « conteneur pour le transfert de données ». Il n'est pas obligé d'être lié à une table. Il n'est pas obligé d'avoir @Entity. Il n'est pas obligé d'avoir une clé métier ou de suivre les règles d'identité d'une entity. Son objectif est plus simple : exprimer la forme des données nécessaire à la frontière entre les couches (ou entre un module et le « monde extérieur »), et le faire le plus explicitement possible.
Très souvent, il est pratique de rendre un DTO immuable, pour qu'il ne puisse pas être accidentellement « modifié en cours de route ». Et ici, les records Java sont presque un cadeau (pardon pour le jeu de mots) : ils sont courts, lisibles et disent explicitement « ce sont des données ».
Par exemple, un DTO pour afficher un produit « vers l'extérieur » (peu importe, vers le web ou vers une autre couche) peut ressembler à ceci :
package com.example.shopdatajpa.catalog.dto;
import java.math.BigDecimal;
public record ProductDto(
Long id, // Identifiant : nécessaire pour les références/opérations, mais pas obligatoire dans chaque DTO.
String sku, // Référence/code du produit — ce que nous voulons transmettre entre les couches.
String name, // Nom pour l'affichage/la logique du use case.
BigDecimal price // Prix sous forme « brute », sans détails JPA et sans formatage pour l'interface utilisateur.
) {
// Par défaut, un record est immutable : moins de chances de « modifier accidentellement » le DTO en cours de route.
}
Remarquez deux choses.
Premièrement, un DTO peut inclure uniquement les champs dont nous avons besoin dans un scénario donné. Par exemple, si à un endroit nous n'avons pas besoin de id (par exemple, lors de la création d'un produit), nous créons un autre DTO sans id. C'est normal.
Deuxièmement, le DTO n'entraîne pas de rôle JPA. Il n'y a pas d'annotations ORM, pas d'exigences de constructeur « pour Hibernate », pas de sémantique subtile d'identité. Ce sont juste des données.
Maintenant, comparons cela à l'entity encore une fois, non pas par le code mais par le sens :
L'entity est responsable de l'exactitude du modèle de persistance. Le DTO est responsable du transfert pratique des données. Ces rôles sont différents, et essayer de les « coller » revient à demander au cuisinier d'être également serveur et comptable. Il existe de petits cafés où cela se passe ainsi, mais ne vous étonnez pas ensuite que la soupe soit salée, l'addition incorrecte et l'humeur du cuisinier mystérieuse.
4. Frontière : mappage et conversion
Les mots « entity ne doit pas être DTO » sonnent bien, mais des questions du style « OK. Mais comment vivre ? » apparaissent. La réponse est ennuyeuse mais efficace : il faut un mappage explicite. C'est-à-dire que quelqu'un dans le code doit prendre une entity et la transformer en DTO, et vice versa. Et ce « quelqu'un » doit se trouver à l'endroit où la frontière passe réellement, et non se répandre « un peu partout ».
L'erreur la plus fréquente chez un débutant est de faire la conversion « là où c'est tombé ». À un endroit, toDto vit dans l'entity, à un autre dans le service, à un troisième dans le contrôleur (que nous n'avons pas du tout pour l'instant), et à un quatrième dans le test par copier-coller. Ensuite, vous modifiez un champ (par exemple, vous renommez name en title) et vous allez récolter les erreurs de compilation dans tout le projet.
Dans un projet d'apprentissage, il vaut mieux choisir un style simple et compréhensible : une classe mapper séparée à côté du package feature. Par exemple, ProductMapper dans le périmètre catalog. Et qu'il fasse deux opérations : toDto(Product) et fromRequest(CreateProductRequest) (ou similaire). Sans frameworks, sans magie, juste du code Java.
Un mapper minimal peut ressembler à ceci :
package com.example.shopdatajpa.catalog.dto;
import com.example.shopdatajpa.catalog.entity.Product;
public final class ProductMapper {
private ProductMapper() {
// Interdire la création d'instances : c'est une classe utilitaire avec des méthodes statiques.
}
public static ProductDto toDto(Product product) {
// Point de frontière explicite : à partir de l'entity (modèle de stockage), nous faisons un DTO (modèle de transfert).
// Ici, il est pratique de contrôler quels champs partent « à l'extérieur ».
return new ProductDto(
product.getId(),
product.getSku(),
product.getName(),
product.getPrice()
);
}
}
Le code est court et direct. C'est exactement ce dont nous avons besoin maintenant : que la frontière soit explicite et visible. Lorsque la frontière est visible, l'architecture cesse d'être un « accord dans les têtes » et devient une partie du code.
Et voici la direction inverse — le DTO « en entrée » (par exemple, une requête de création de produit). Nous pouvons créer un DTO séparé qui reflète précisément la forme des données d'entrée. Par exemple :
package com.example.shopdatajpa.catalog.dto;
import java.math.BigDecimal;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
public record CreateProductRequest(
String sku, // Ce que le monde extérieur saisit (client/test/adaptateur).
String name, // Données d'entrée pour la création, pas un « instantané de l'entité ».
BigDecimal price, // Valeur d'entrée du prix (ensuite, la logique métier peut le vérifier).
ProductStatus status // Exemple de champ qui peut faire partie du scénario métier de création.
) {
// Important : il n'y a pas d'id — il est généralement généré de notre côté (BDD/application).
}
Et la conversion en entity :
package com.example.shopdatajpa.catalog.dto;
import com.example.shopdatajpa.catalog.entity.Product;
public final class ProductMapper {
public static Product fromRequest(CreateProductRequest req) {
// Conversion « vers l'intérieur » : nous transformons le DTO externe en entity pour la sauvegarde.
// Ici, il est pratique de faire la normalisation/préparation des données (dans un projet réel, souvent + validation).
Product p = new Product();
p.setSku(req.sku());
p.setName(req.name());
p.setPrice(req.price());
p.setStatus(req.status());
return p;
}
}
Remarquez un effet psychologique important : CreateProductRequest n'essaie pas du tout d'être « presque une entity ». Il n'a pas de id. Et c'est correct : id ne doit pas venir « de l'extérieur » comme un champ obligatoire lors de la création si nous le générons nous-mêmes dans la base. Autrement dit, le DTO nous aide à préserver le modèle des absurdités.
5. DTO dans mini-shop autour de Product
Si l'on regarde mini-shop comme un projet vivant, on découvre presque immédiatement qu'« un seul DTO pour tout » ne fonctionne pas non plus. Mais c'est déjà un bon signe : cela signifie que nous avons commencé à penser en use cases. Pour différents scénarios, différentes formes de données sont nécessaires.
Pour la création d'un produit, il faut un ensemble de champs sans id, mais avec les champs nécessaires à la création. Pour « l'affichage du produit dans la liste du catalogue », il faut un autre ensemble : peut-être un nom court, un prix et un statut. Pour la « fiche détaillée du produit » (que nous renforcerons bientôt avec ProductDetails), il faut une forme plus riche, avec une description, un fabricant, une garantie, etc.
Et c'est là qu'une discipline très importante intervient : n'essayez pas de résoudre cela en ajoutant de nouveaux champs à l'entity « pour que tout le monde soit à l'aise ». Parce qu'ainsi vous transformez à nouveau l'entity en un objet universel. La bonne démarche est de créer des DTO pour des frontières spécifiques.
Par exemple, une « ligne de catalogue » peut être ainsi :
package com.example.shopdatajpa.catalog.dto;
import java.math.BigDecimal;
public record ProductCatalogRow(
Long id, // Nécessaire pour ouvrir la fiche / effectuer une action sur un produit spécifique.
String sku, // Généralement affiché dans la liste, aide à rechercher/identifier le produit.
String name, // Nom court pour la liste.
BigDecimal price // Prix pour la liste (sans les détails de la fiche).
) {
}
Et une « fiche détaillée » peut être différente (pour l'instant sans ProductDetails, juste une idée de forme) :
package com.example.shopdatajpa.catalog.dto;
import java.math.BigDecimal;
public record ProductCardDto(
Long id, // Identifiant du produit.
String sku, // Référence/code.
String name, // Nom complet pour la fiche.
BigDecimal price, // Prix.
String description // Détails de la fiche : c'est une « forme de données », pas une partie obligatoire du modèle de persistance.
) {
}
Oui, ce sont « encore des classes ». Mais elles ne prolifèrent pas sans contrôle. Elles reflètent des frontières réelles : un use case — une forme de données. Et le plus agréable : si demain la fiche a besoin d'ajouter un champ, vous n'êtes pas obligé de modifier l'entity. Vous modifiez le DTO et le mappage. Le modèle de persistance reste stable et ne se transforme pas en « constructeur pour tous les besoins de l'interface utilisateur ».
Maintenant, un petit lien avec les sujets d'aujourd'hui concernant @Embeddable. Un piège fréquent : « Puisque DeliveryAddress est une classe séparée, on peut donc l'utiliser aussi comme DTO ! ». Formellement, bien sûr, vous pouvez le transmettre quelque part. Mais conceptuellement, cela fait toujours partie du modèle de persistance. Des annotations JPA, des règles de colonnes, des contraintes peuvent y apparaître. Il est donc plus sûr et plus clair de séparer : DeliveryAddress comme partie d'une entity, c'est une chose, DeliveryAddressDto comme forme de transport, c'en est une autre. Parfois ils coïncident dans les champs, mais cette coïncidence doit être consciente, pas accidentelle.
Et oui, vous remarquerez que nous disons constamment « à l'extérieur », même s'il n'y a pas de couche web complète dans notre projet data-layer-first pour l'instant. C'est normal. Nous construisons une habitude : ne pas mélanger le modèle de persistance avec le modèle de transfert de données. Même si « à l'extérieur » ne sont pour l'instant que des tests, « à l'extérieur » existe toujours. Et dans le travail réel, « à l'extérieur » est presque toujours présent.
6. Erreurs typiques lors du mélange d'entity et de DTO
Erreur n°1 : « Une classe pour toutes les couches, et la vie deviendra plus simple ».
Une telle solution ressemble à une économie de temps, mais en pratique c'est un crédit à taux élevé. L'entity commence à se charger de champs « pour l'affichage », « pour le formulaire », « pour une sérialisation pratique ». Au final, vous obtenez une classe qui n'est ni pratique pour la base de données, ni prévisible pour le transfert de données. Généralement, cela se termine par l'introduction d'un DTO de toute façon, mais il est déjà trop tard et c'est douloureux.
Erreur n°2 : On glisse des annotations JPA dans le DTO « au cas où ».
Parfois, quelqu'un pense : « Eh bien, le DTO ressemble à une entity, qu'il soit aussi @Entity, au cas où ». C'est comme porter un casque sous la douche : vous êtes peut-être protégé, mais c'est étrange et inconfortable. Le DTO doit rester une forme de transport pure. S'il a besoin d'annotations, il s'agit le plus souvent d'annotations de sérialisation ou de validation, pas d'ORM. Mais même celles-ci doivent être ajoutées avec précaution, pour ne pas recommencer à mélanger les couches.
Erreur n°3 : On expose l'entity à l'extérieur, puis on s'étonne des « modifications étranges ».
Une entity est un objet avec un cycle de vie, et nous avons déjà fait connaissance avec l'idée de managed/detached. Lorsque l'entity part « à l'extérieur », vers des couches où l'on n'y pense pas, elle commence à être modifiée « comme un objet ordinaire ». Ensuite, on s'étonne : pourquoi les données se comportent différemment, pourquoi l'objet « tantôt fonctionne, tantôt non ». Même sans connaissance approfondie d'Hibernate, une règle d'hygiène est utile ici : l'entity est un modèle interne de la couche de données, pas un vecteur de données universel.
Erreur n°4 : Le mappage est dispersé dans le code, et chaque endroit fait « comme ça vient ».
Aujourd'hui vous avez écrit toDto dans le service, demain dans le test, après-demain « rapidement dans le contrôleur ». Dans un mois, vous ajoutez un champ et vous attrapez une dizaine de formats incompatibles. Mieux vaut avoir un ou deux points de conversion explicites (mappers), même simples et manuels. C'est ennuyeux, mais c'est ce genre d'ennui qui soutient la maintenabilité.
Erreur n°5 : On fait un DTO « comme une entity, mais sans @Entity », sans se demander « à quoi sert-il ? ».
Le DTO n'est pas un rituel. Il est nécessaire lorsque le rôle de l'objet change : stockage contre transfert. Si le DTO copie l'entity un-à-un et ne fait rien d'autre, ce n'est pas toujours mauvais, mais cela doit être conscient. Si le DTO apparaît simplement parce que « c'est comme ça », vous risquez d'obtenir un tas de classes sans sens. Un bon DTO répond toujours à la question : « quel use case le nécessite et quelle frontière il formalise ? ».
GO TO FULL VERSION