Aller au contenu

Spring Boot : MapStruct & DTO

C’est quoi ?

MapStruct est une bibliothèque Java open source qui génère automatiquement le code de conversion entre deux objets (entre une entité JPA et un DTO). Elle est statique et compilée donc pas de réflexion à l’exécution, ce qui la rend très performante.

Quel est l’intérêt d’utiliser MapStruct et la notion de DTO ?

Séparer les entités persistantes de la couche de présentation grâce à un mapper automatique (ici, MapStruct)

Voici un extrait du corrigé de l’application Waouf Waouf (TP UML) :

package com.ffc.wouaf.web.mapper;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

import com.ffc.wouaf.model.Chien;
import com.ffc.wouaf.web.dto.ChienDto;
@Mapper(componentModel = "spring")

public interface ChienMapper {

  @Mapping(target="etat", expression="java(chien.getEtat().name())")
  @Mapping(target="raceId", source="race.id")
  @Mapping(target="raceCode", source="race.code")
  @Mapping(target="raceNom", source="race.nom")
  @Mapping(target="proprietaireId", source="proprietaire.id")
  @Mapping(target="proprietaireNom", source="proprietaire.nom")
  ChienDto toDto(Chien chien);
}

Explications :

Quand vous utilisez JPA/Hibernate, vous manipulez des entités (@Entity) qui sont liées directement à la base de données.

Ces entités :

En générale, exposer directement ces entités dans une API REST (via un votre @RestController) entraîne :

La bonne pratique consiste à transformer les entités (dans notre exemple : Chien, Race, Proprietaire) en objets simples de transfert de données : les fameux DTO (Data Transfer Objects). C’est là que MapStruct intervient !

Mais à quoi sert-il ?

MapStruct est une librairie qui génère automatiquement le code de conversion entre deux objets Java, ici entre notre entité JPA (Chien) et le DTO (ChienDto).

L’objectif étant d’automatiser le “copier-coller intelligent” de champs sans écrire des centaines de code de la forme dto.setXXX(entity.getXXX()).

Détail et explication du code

@Mapper(componentModel = "spring")
public interface ChienMapper {

@Mapper(componentModel = “spring”)

Il indique à MapStruct de générer une classe d’implémentation automatiquement.

L’option componentModel = "spring" fait en sorte que cette classe soit un bean Spring injectable via @Autowired ou par constructeur (recommandée).

On pourra écrire :

private final ChienMapper chienMapper;

public ChienService(ChienMapper chienMapper) {
    this.chienMapper = chienMapper;
}

Méthode de conversion

@Mapping(target="etat", expression="java(chien.getEtat().name())")
@Mapping(target="raceId", source="race.id")
@Mapping(target="raceCode", source="race.code")
@Mapping(target="raceNom", source="race.nom")
@Mapping(target="proprietaireId", source="proprietaire.id")
@Mapping(target="proprietaireNom", source="proprietaire.nom")
ChienDto toDto(Chien chien);

La méthode toDto(Chien chien) demande à MapStruct de générer une méthode qui :

Tableau explicatif des annotations

Annotation Signification Exemple du résultat dans le DTO
@Mapping(target="etat", expression="java(chien.getEtat().name())") Appelle getEtat() sur l’objet chien (enum) et stocke son nom (String) dans le champ etat du DTO "etat": "EN_BONNE_SANTE"
@Mapping(target="raceId", source="race.id") Va chercher chien.getRace().getId() et le place dans ChienDto.raceId "raceId": 5
@Mapping(target="raceCode", source="race.code") Récupère le code de la race liée "raceCode": "LABRADOR"
@Mapping(target="raceNom", source="race.nom") Récupère le nom de la race liée "raceNom": "Labrador Retriever"
@Mapping(target="proprietaireId", source="proprietaire.id") Récupère l’ID du propriétaire associé "proprietaireId": 12
@Mapping(target="proprietaireNom", source="proprietaire.nom") Récupère le nom du propriétaire associé "proprietaireNom": "Dupont"

Exemple concret

Déclaration de notre entity Chien en JPA

@Entity
public class Chien {
  @Id private Long id;
  private EtatChien etat;

  @ManyToOne private Race race;
  @ManyToOne private Proprietaire proprietaire;
}

DTO léger pour l’API

public class ChienDto {
  private Long id;
  private String etat;
  private Long raceId;
  private String raceCode;
  private String raceNom;
  private Long proprietaireId;
  private String proprietaireNom;
}

Appel simple pour générer notre objet DTO

ChienDto chienDto = chienMapper.toDto(chien);

Grâce à notre Mapper prédéfini :

ChienDto dto = new ChienDto();
dto.setEtat(chien.getEtat().name());
dto.setRaceId(chien.getRace().getId());
dto.setRaceCode(chien.getRace().getCode());
dto.setRaceNom(chien.getRace().getNom());
dto.setProprietaireId(chien.getProprietaire().getId());
dto.setProprietaireNom(chien.getProprietaire().getNom());

Du coup, MapStruct le compile à la volée au moment du build, pas besoin d’écrire plus de code !

Intégration dans un service Spring

@Service
@RequiredArgsConstructor
public class ChienService {
    private final ChienRepository chienRepository;
    private final ChienMapper chienMapper;

    public List<ChienDto> getAllChiens() {
        return chienRepository.findAll().stream()
                .map(ChienMapper::toDto)
                .toList();
    }
}

L’API renverra des ChienDto bien formatés, sans jamais exposer les entités JPA !

Avantages sous forme de tableau

Critère Sans MapStruct Avec MapStruct
Code à écrire Beaucoup de dto.setXxx() Zéro code de mapping
Performance Exécution lente (réflexion, frameworks dynamiques) Compilation statique, donc plutôt rapide
Sécurité Risque d’oublis / NullPointer Mapping strict et vérifié à la compilation
Maintenabilité Difficile à étendre Très facile : ajoute un champ -> MapStruct alerte si oublié

Par conséquent :

Donc, on transforme les entités JPA en DTOs sans trop d’effort !

Voilà, maintenant, vous savez tout sur la notion de DTO et MapStruct

Documentation (source)

Propriétes de @Mapping (attention, de MapStruct uniquement)

Propriété Description / Utilité Exemple d’utilisation Commentaire
source Nom du champ dans l’objet source à copier @Mapping(source = "nom", target = "nomClient") Sert à renommer une propriété entre deux objets
target Nom du champ dans l’objet destination (DTO, par ex.) @Mapping(target = "raceNom", source = "race.nom") Obligatoire si on veut mapper un champ spécifique
expression Exécute une expression Java pour calculer la valeur du champ @Mapping(target = "etat", expression = "java(chien.getEtat().name())") Parfait pour mapper des enums ou des calculs personnalisés
constant Affecte une valeur constante au champ cible @Mapping(target = "type", constant = "ANIMAL") Utile pour remplir un champ fixe dans un DTO
defaultValue Valeur utilisée si le champ source est null @Mapping(source = "pays", target = "pays", defaultValue = "France") Évite les valeurs nulles dans les réponses JSON
defaultExpression Expression Java exécutée si la source est null @Mapping(target = "dateCreation", defaultExpression = "java(LocalDate.now())") Similaire à defaultValue, mais calculée dynamiquement
qualifiedByName Indique le nom d’une méthode de mapping personnalisée à utiliser @Mapping(source = "dateNaissance", target = "age", qualifiedByName = "calculerAge") Permet d’utiliser une méthode utilitaire annotée avec @Named("calculerAge")
qualifiedBy Référence une annotation de qualification (plus générique que qualifiedByName) @Mapping(source = "dateNaissance", target = "age", qualifiedBy = AgeQualifier.class) Sert à appliquer un convertisseur spécifique
dateFormat Définit le format de date à utiliser lors de la conversion @Mapping(source = "dateNaissance", target = "dateNaissance", dateFormat = "dd/MM/yyyy") MapStruct gère automatiquement le formatage String ↔ Date
numberFormat Spécifie le format numérique à appliquer @Mapping(source = "salaire", target = "salaire", numberFormat = "#,###.00") Pour formatter les nombres en chaînes
ignore Ignore complètement un champ dans le mapping @Mapping(target = "password", ignore = true) Très utile pour masquer des données sensibles
nullValuePropertyMappingStrategy Définit le comportement si la propriété source est null @Mapping(target = "adresse", source = "adresse", nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) Évite d’écraser une valeur existante avec null
nullValueCheckStrategy Force ou non la vérification de null avant d’appeler un getter source @Mapping(target = "nom", source = "nom", nullValueCheckStrategy = ALWAYS) Utile si la source peut être null profondément dans un graphe
nullValueMappingStrategy Définit la valeur de retour si l’objet source complet est null @Mapping(target = "dto", source = "entity", nullValueMappingStrategy = RETURN_DEFAULT) Retourne un objet vide plutôt que null
resultType Spécifie un type de destination particulier (utile pour les collections ou sous-types) @Mapping(target = "animal", source = "animalEntity", resultType = ChienDto.class) Utilisé avec des interfaces génériques ou des polymorphismes
dependsOn Indique qu’un champ dépend d’un autre mapping déjà effectué @Mapping(target = "fullName", dependsOn = {"prenom", "nom"}) Garantit que les autres champs sont mappés avant celui-ci
Type d’usage Propriétés MapStruct clés
Renommer ou relier un champ source, target
Transformer une valeur expression, qualifiedByName, dateFormat, numberFormat
Définir des valeurs par défaut defaultValue, defaultExpression
Ignorer ou protéger des champs ignore, nullValuePropertyMappingStrategy, dependsOn
Gérer les cas particuliers resultType, nullValueMappingStrategy

Explications des Annotations principales

Le lien vers la documentation est en fin de page de ce cours pratique.

Annotation Documentation officielle Description
@Mapper Définit une interface de mapping. MapStruct génère automatiquement l’implémentation.  
@Mappings (optionnelle) Conteneur de plusieurs @Mapping. (Souvent remplacée par plusieurs @Mapping successifs.)  
@Mapping Définit les correspondances entre champs (source → target, expression, etc.).  
@InheritInverseConfiguration Permet de générer automatiquement le mapping inverse (ex : Dto → Entity).  
@InheritConfiguration Réutilise la configuration d’un mapping existant pour éviter la duplication.  
@AfterMapping Exécute du code après le mapping généré (utile pour compléter ou ajuster un DTO).  
@BeforeMapping Exécute du code avant le mapping (utile pour préparer les données).  
@MappingTarget Indique l’objet cible à remplir (utilisé dans les @AfterMapping ou update).  
@Context Injecte un contexte externe (ex : service ou paramètre de configuration).  
@DecoratedWith Permet de surcharger ou d’enrichir le mapper généré avec une implémentation personnalisée.  
@Qualifier Spécifie un mapping particulier à utiliser (par exemple, un convertisseur d’unités).  
@Named Nomme une méthode de mapping pour pouvoir la cibler avec qualifiedByName.  

Exemple avec l’utilisation de @InheritInverseConfiguration :

@InheritInverseConfiguration
Chien toEntity(ChienDto chienDto);

Pour info, **MapStruct génère automatiquement la classe ChienMapperImpl.java pendant la compilation. Cette classe contient tout le code de conversion, optimisé et typé.

Illustration avec @Aftermappinget @Mappingtarget :

@Mapper(componentModel = "spring")
public interface AdherentMapper {

    AdherentDto toDto(Adherent entity);

    @AfterMapping
    default void enrichirDto(Adherent entity, @MappingTarget AdherentDto dto) {
        dto.setNomComplet(entity.getPrenom() + " " + entity.getNom());
    }
}

Quelques messages d’erreurs courants

Erreur Cause Solution
Unmapped target property Tu n’as pas mappé tous les champs du DTO Ajoute un @Mapping ou règle unmappedTargetPolicy = ReportingPolicy.IGNORE
Can't map property X to Y Type incompatible (ex : DateString) Ajoute dateFormat ou qualifiedByName
Ambiguous mapping methods found Plusieurs méthodes possibles pour un même type Spécifie qualifiedByName
NullPointerException au mapping Sous-objet null dans la source Ajoute nullValuePropertyMappingStrategy = IGNORE

Vous pouvez ajouter ce code pour une vérification stricte :

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)

Notes

Les propriétés source et target sont les plus fréquentes : elles définissent le lien entre les champs.

Les propriétés comme expression, constant, defaultValue et ignore sont très pratiques pour des cas spécifiques.

MapStruct génère du code à la compilation (pas en runtime) : Donc pas de perte de performance.

On peut combiner plusieurs @Mapping() comme ce que l’on a fait dans les exemples de code.

mapstruct.org

Exemple de classe d’implémentation générée avec MapStruct (ChienMapper)

Elle correspond à l’implémentation de notre Interface ChienMapper définie plus haut.

```java package com.ffc.wouaf.web.mapper;

import com.ffc.wouaf.model.Adherent; import com.ffc.wouaf.model.Chien; import com.ffc.wouaf.model.Race; import com.ffc.wouaf.web.dto.ChienDto; import javax.annotation.processing.Generated; import org.springframework.stereotype.Component;

@Generated( value = “org.mapstruct.ap.MappingProcessor”, date = “2025-10-16T09:37:26+0200”, comments = “version: 1.5.5.Final, compiler: Eclipse JDT (IDE) 3.43.0.v20250819-1513, environment: Java 21.0.8 (Eclipse Adoptium)” ) @Component public class ChienMapperImpl implements ChienMapper {

@Override
public ChienDto toDto(Chien chien) {
    if ( chien == null ) {
        return null;
    }

    Long raceId = null;
    String raceCode = null;
    String raceNom = null;
    Long proprietaireId = null;
    String proprietaireNom = null;
    Long id = null;
    String numeroTatouage = null;
    String nom = null;

    raceId = chienRaceId( chien );
    raceCode = chienRaceCode( chien );
    raceNom = chienRaceNom( chien );
    proprietaireId = chienProprietaireId( chien );
    proprietaireNom = chienProprietaireNom( chien );
    id = chien.getId();
    numeroTatouage = chien.getNumeroTatouage();
    nom = chien.getNom();

    String etat = chien.getEtat().name();

    ChienDto chienDto = new ChienDto( id, numeroTatouage, nom, etat, raceId, raceCode, raceNom, proprietaireId, proprietaireNom );

    return chienDto;
}

private Long chienRaceId(Chien chien) {
    if ( chien == null ) {
        return null;
    }
    Race race = chien.getRace();
    if ( race == null ) {
        return null;
    }
    Long id = race.getId();
    if ( id == null ) {
        return null;
    }
    return id;
}

private String chienRaceCode(Chien chien) {
    if ( chien == null ) {
        return null;
    }
    Race race = chien.getRace();
    if ( race == null ) {
        return null;
    }
    String code = race.getCode();
    if ( code == null ) {
        return null;
    }
    return code;
}

private String chienRaceNom(Chien chien) {
    if ( chien == null ) {
        return null;
    }
    Race race = chien.getRace();
    if ( race == null ) {
        return null;
    }
    String nom = race.getNom();
    if ( nom == null ) {
        return null;
    }
    return nom;
}

private Long chienProprietaireId(Chien chien) {
    if ( chien == null ) {
        return null;
    }
    Adherent proprietaire = chien.getProprietaire();
    if ( proprietaire == null ) {
        return null;
    }
    Long id = proprietaire.getId();
    if ( id == null ) {
        return null;
    }
    return id;
}

private String chienProprietaireNom(Chien chien) {
    if ( chien == null ) {
        return null;
    }
    Adherent proprietaire = chien.getProprietaire();
    if ( proprietaire == null ) {
        return null;
    }
    String nom = proprietaire.getNom();
    if ( nom == null ) {
        return null;
    }
    return nom;
} }```