🎯 OBJECTIF
Comprendre comment :
target = "." et source = "."@Condition quand un champ doit être copié ou nonunmappedTargetPolicy et null pour ne rien laisser passer en silence🧠 MODÈLE MENTAL
Une application backend passe son temps à copier des champs d'un objet vers un autre. Entité vers DTO d'API. Message Kafka vers commande métier. Requête HTTP vers entité. Ce code est bête, répétitif et fragile. On l'oublie de mettre à jour quand un champ est ajouté. Et le bug ne se voit qu'en production, sous la forme d'un null inattendu.
Deux familles d'outils existent pour éviter d'écrire ce code. La première utilise la réflexion à l'exécution (ModelMapper, Dozer). Elle devine les correspondances par nom de champ, au runtime. C'est lent, opaque, et une erreur de mapping ne se voit que quand le code s'exécute.
MapStruct prend l'autre route. C'est un annotation processor. Vous écrivez une interface Java avec des signatures de méthodes. Au moment de javac, MapStruct lit ces signatures et écrit une vraie classe Java, avec des getters et des setters, que vous pouvez ouvrir et lire. Si un champ cible n'a pas de source, la compilation échoue. Si les types ne correspondent pas, la compilation échoue. Le mapping devient du code ordinaire : rapide, typé, débogable, sans dépendance à l'exécution.
L'idée à retenir : MapStruct ne fait rien de magique. Il écrit à votre place le code que vous auriez écrit à la main, et il le vérifie pour vous.
javac qui lit les annotations et génère du code source avant la compilation finale.@Mapper qui déclare les méthodes de conversion.target/generated-sources/annotations.default (via Mappers.getMapper), spring, cdi, jsr330, jakarta.@Named ou custom) qui désigne une méthode de conversion précise quand plusieurs sont possibles.!= null, remplaçable par @Condition.MapStruct s'exécute pendant la compilation. Le flux est le suivant.
flowchart LR
A["OrderMapper.java<br/>(interface + annotations)"] --> B["javac"]
B --> C["mapstruct-processor<br/>(annotation processor)"]
C --> D["OrderMapperImpl.java<br/>(généré, lisible)"]
D --> B2["javac"]
B2 --> E["OrderMapperImpl.class"]
C -. "champ cible sans source,<br/>type incompatible" .-> F["Erreur de compilation"]mermaidUn exemple minimal. L'entité et le DTO :
public class Order {
private Long id;
private String customerEmail;
private BigDecimal totalAmount;
private LocalDateTime createdAt;
// getters / setters
}
public record OrderDto(Long id, String email, BigDecimal total, String createdAt) {}javaLe mapper :
@Mapper
public interface OrderMapper {
@Mapping(target = "email", source = "customerEmail")
@Mapping(target = "total", source = "totalAmount")
@Mapping(target = "createdAt", dateFormat = "yyyy-MM-dd'T'HH:mm:ss")
OrderDto toDto(Order order);
}javaLe code généré (extrait de OrderMapperImpl) :
@Generated("org.mapstruct.ap.MappingProcessor")
public class OrderMapperImpl implements OrderMapper {
@Override
public OrderDto toDto(Order order) {
if ( order == null ) { // ✓ null-check automatique sur la source
return null;
}
Long id = order.getId();
String email = order.getCustomerEmail();
BigDecimal total = order.getTotalAmount();
String createdAt = null;
if ( order.getCreatedAt() != null ) {
createdAt = DateTimeFormatter.ofPattern( "yyyy-MM-dd'T'HH:mm:ss" ).format( order.getCreatedAt() );
}
return new OrderDto( id, email, total, createdAt ); // ✓ record : constructeur canonique
}
}javaCe code est du Java ordinaire. On peut poser un breakpoint dedans. On peut le lire dans target/generated-sources/annotations. Aucune bibliothèque n'est chargée à l'exécution : le jar mapstruct ne contient que les annotations.
🔑 Conclusion clé
MapStruct déplace le coût du mapping de l'exécution vers la compilation. Le résultat est aussi rapide que du code manuel, et les erreurs sont bloquantes au build.
Deux artefacts : mapstruct (annotations, scope compile) et mapstruct-processor (uniquement dans annotationProcessorPaths). Version stable actuelle : 1.6.3.
<properties>
<mapstruct.version>1.6.3</mapstruct.version>
<lombok.version>1.18.34</lombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<!-- ⚠️ Lombok AVANT MapStruct : MapStruct doit voir les getters générés -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<!-- ✓ obligatoire avec Lombok >= 1.18.16 -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<!-- ✓ options globales, évite de les répéter sur chaque @Mapper -->
<arg>-Amapstruct.defaultComponentModel=spring</arg>
<arg>-Amapstruct.unmappedTargetPolicy=ERROR</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>xml⚡ Le piège Lombok
Sans lombok-mapstruct-binding, MapStruct peut s'exécuter avant que Lombok ait généré les getters et setters. Symptôme : erreur Unknown property ou No property named "x" exists, alors que le champ est bien là. Le binding force l'ordre. Il faut aussi que Lombok soit listé avant le processor MapStruct.
MapStruct convertit tout seul, sans annotation :
int ↔ Integer, long ↔ int avec cast).String, avec numberFormat si besoin.Enum ↔ String (par name()).java.time ↔ String avec dateFormat, java.util.Date ↔ LocalDateTime, etc.String ↔ UUID, BigDecimal ↔ BigInteger.@Mapping@Mapper
public interface CustomerMapper {
@Mapping(target = "fullName", expression = "java(customer.getFirstName() + \" \" + customer.getLastName())")
@Mapping(target = "status", constant = "ACTIVE") // valeur fixe
@Mapping(target = "country", source = "address.country", defaultValue = "FR") // fallback si null
@Mapping(target = "internalNotes", ignore = true) // ✓ explicite : on ne mappe pas
@Mapping(target = "zipCode", source = "address.postalCode") // ✓ chemin imbriqué
CustomerDto toDto(Customer customer);
}java| Attribut | Rôle | Vérifié à la compilation ? |
|---|---|---|
source |
Renommer ou aller chercher un champ imbriqué (address.city) ; "." = la source entière |
✅ Oui |
target |
Champ cible (obligatoire) ; "." = la cible entière |
✅ Oui |
ignore |
Ne pas alimenter le champ, sans erreur unmapped |
✅ Oui |
constant |
Valeur littérale | ✅ Oui (type) |
defaultValue |
Valeur si la source est null | ✅ Oui (type) |
expression |
Code Java brut dans une String | ❌ Non |
defaultExpression |
Code Java brut, utilisé seulement si la source est null | ❌ Non |
conditionExpression |
Code Java brut qui renvoie un boolean : copier ou non | ❌ Non |
dateFormat / numberFormat |
Pattern de conversion | ✅ Partiel |
qualifiedByName |
Choisir une méthode de conversion précise | ✅ Oui |
conditionQualifiedByName |
Choisir une méthode @Condition précise |
✅ Oui |
⚡ expression n'est pas vérifiée
Le contenu de expression = "java(...)" est copié tel quel dans la classe générée. Même chose pour defaultExpression et conditionExpression. Une faute de frappe donne une erreur de compilation cryptique dans MapperImpl. Un import manquant échoue aussi. Il faut privilégier une méthode default dans le mapper, ou une méthode @Named / @Condition. On garde les expressions pour les cas triviaux, par exemple defaultExpression = "java(UUID.randomUUID())".
MapStruct cherche une méthode existante pour chaque sous-type. Il la trouve dans le mapper courant ou dans les mappers déclarés avec uses.
@Mapper
public interface AddressMapper {
AddressDto toDto(Address address);
}
@Mapper(uses = AddressMapper.class) // ✓ délégation, injectée si componentModel = spring
public interface CustomerMapper {
CustomerDto toDto(Customer customer); // customer.address → AddressMapper.toDto
}javaSi aucune méthode n'existe pour un sous-type, MapStruct en génère une automatiquement, en privé, dans MapperImpl. C'est pratique mais peu contrôlable. Une méthode explicite est préférable dès que le sous-type a des règles.
target = "." et source = "."Le point veut dire « l'objet entier », pas un de ses champs. Il s'emploie dans deux sens.
Aplatir avec target = "." (depuis 1.4). La source a un sous-objet. La cible est plate. On veut verser les champs du sous-objet directement dans la cible.
public class Customer {
private String name;
private Address address; // street, city, zipCode
}
public record CustomerFlatDto(String name, String street, String city, String zipCode) {}
@Mapper
public interface CustomerMapper {
@Mapping(target = ".", source = "address") // ✓ address.street → street, address.city → city, address.zipCode → zipCode
CustomerFlatDto toFlat(Customer customer);
}javaSans le point, il faudrait un @Mapping(target = "street", source = "address.street") par champ. Avec, MapStruct fait la correspondance par nom entre les champs de address et ceux de la cible. Si un même nom existe à deux niveaux (par exemple name dans Customer et dans Address), MapStruct signale une ambiguïté. Il faut alors trancher avec un @Mapping explicite.
Imbriquer avec source = ".". C'est l'inverse. La source est plate. La cible a des sous-objets. Chaque sous-objet doit être construit à partir de la source entière.
public record ShipmentEvent(String orderId, String carrier, String trackingUrl, LocalDateTime shippedAt) {}
public record ShipmentDto(OrderRef order, CarrierInfo carrier) {}
public record OrderRef(String id) {}
public record CarrierInfo(String name, String trackingUrl) {}
@Mapper
public interface ShipmentMapper {
@Mapping(target = "order", source = ".") // ✓ l'événement entier → toOrderRef
@Mapping(target = "carrier", source = ".") // ✓ l'événement entier → toCarrierInfo
ShipmentDto toDto(ShipmentEvent event);
@Mapping(target = "id", source = "orderId")
OrderRef toOrderRef(ShipmentEvent event);
@Mapping(target = "name", source = "carrier")
CarrierInfo toCarrierInfo(ShipmentEvent event);
}javaComment MapStruct choisit la méthode : par le type de retour. Pour order, il cherche une méthode qui prend ShipmentEvent et renvoie OrderRef. Pour carrier, une qui renvoie CarrierInfo. Si deux champs cibles ont le même type, les deux méthodes candidates sont ambiguës. Il faut alors @Named sur les méthodes et qualifiedByName sur les @Mapping.
Un usage utile : source = "." avec une @Condition. La condition reçoit alors l'objet source entier, et peut décider à partir de plusieurs champs.
@Mapping(target = "carrier", source = ".", conditionQualifiedByName = "isShipped")
ShipmentDto toDto(ShipmentEvent event);
@Condition
@Named("isShipped")
default boolean isShipped(ShipmentEvent event) {
return event.carrier() != null && event.shippedAt() != null; // ✓ deux champs consultés
}java🔑 Règle
target = "." quand la source est plus profonde que la cible. source = "." quand la cible est plus profonde que la source. Dans les deux cas, écrire une méthode explicite par sous-objet plutôt que laisser MapStruct en générer une en privé.
Une méthode qui mappe Order → OrderDto suffit pour que List<Order> → List<OrderDto> fonctionne. Il faut juste déclarer la signature.
@Mapper
public interface OrderMapper {
OrderDto toDto(Order order);
List<OrderDto> toDtos(List<Order> orders); // ✓ itère et appelle toDto
Map<String, OrderDto> toDtoById(Map<String, Order> orders);
}javaSet, Collection, Iterable et les Stream sont supportés de la même façon.
@ValueMapping@Mapper
public interface StatusMapper {
@ValueMapping(target = "OPEN", source = "CREATED")
@ValueMapping(target = "OPEN", source = "CONFIRMED")
@ValueMapping(target = "CLOSED", source = "SHIPPED")
@ValueMapping(target = MappingConstants.NULL, source = MappingConstants.ANY_REMAINING) // ✓ tout le reste → null
PublicStatus toPublic(InternalStatus status);
}javaSans ANY_REMAINING ni ANY_UNMAPPED, une valeur source inconnue lève une IllegalArgumentException à l'exécution. MapStruct vérifie à la compilation que chaque constante source est couverte, et échoue sinon.
@Mapper
public interface ShipmentMapper {
@Mapping(target = "orderId", source = "order.id")
@Mapping(target = "carrier", source = "carrierInfo.name")
@Mapping(target = "trackingUrl", source = "carrierInfo.url")
ShipmentDto toDto(Order order, CarrierInfo carrierInfo); // ✓ deux paramètres
}javaQuand plusieurs sources ont un champ du même nom, il faut qualifier avec le nom du paramètre, sinon erreur de compilation pour ambiguïté.
@MappingTargetJusqu'ici, chaque méthode crée un objet neuf : le code généré fait new OrderDto(...).
@MappingTarget fait autre chose. Il n'y a pas de new. On passe en paramètre un objet qui existe déjà, et MapStruct appelle ses setters dessus. La méthode renvoie void (ou le même objet).
Le cas typique est un PATCH /customers/42. L'objet "existant", c'est l'entité chargée depuis la base. Hibernate la suit (elle est managed). On veut la modifier, pas en créer une deuxième.
Customer customer = customerRepository.findById(42L).orElseThrow(); // 1. l'objet existe déjà, chargé depuis la base
customerMapper.updateFromDto(dto, customer); // 2. MapStruct fait customer.setEmail(dto.email()), etc.
// 3. fin de transaction : Hibernate détecte les changements et génère l'UPDATEjavaSi on faisait Customer c = mapper.toEntity(dto) à la place, on aurait un second objet, sans id, inconnu de Hibernate. Le save ferait un INSERT, ou écraserait tous les champs.
Le mapper :
@Mapper(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface CustomerMapper {
@Mapping(target = "id", ignore = true) // ✓ jamais écrasé
@Mapping(target = "createdAt", ignore = true)
void updateFromDto(CustomerPatchDto dto, @MappingTarget Customer customer);
}javasequenceDiagram
participant C as Controller
participant S as Service (@Transactional)
participant R as Repository
participant M as CustomerMapperImpl
C->>S: patch(id, dto)
S->>R: findById(id)
R-->>S: Customer (managed)
S->>M: updateFromDto(dto, customer)
Note over M: pour chaque champ non null du dto<br/>customer.setX(dto.x())
M-->>S: void
Note over S,R: commit → Hibernate dirty checking<br/>génère l'UPDATEmermaidLe DTO d'un PATCH n'envoie que les champs à changer. Les autres sont null. Avec IGNORE, un champ null dans le DTO ne touche pas l'entité. Sans cette stratégie, la valeur par défaut SET_TO_NULL écrase le champ avec null. Pour un PATCH, c'est presque toujours un bug.
@ConditionPar défaut, MapStruct copie un champ si sa valeur source est != null. C'est le presence check. @Condition (depuis 1.5) permet de remplacer ce test par une méthode à soi.
Cas concret : une chaîne vide venue d'un formulaire ne doit pas écraser une valeur en base.
@Mapper(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface CustomerMapper {
void updateFromDto(CustomerPatchDto dto, @MappingTarget Customer customer);
@Condition
default boolean isNotBlank(String value) { // ✓ s'applique à tous les champs String du mapper
return value != null && !value.isBlank();
}
}javaCode généré pour un champ String :
if ( isNotBlank( dto.getEmail() ) ) { // ✓ remplace le test != null
customer.setEmail( dto.getEmail() );
}javaCe qu'il faut savoir :
@Condition renvoie boolean. Elle est choisie par le type du champ source. Une condition sur String s'applique à tous les champs String du mapper, y compris ceux déclarés dans uses.@Named("emailPresent") sur la condition, et @Mapping(target = "email", conditionQualifiedByName = "emailPresent").@Context, et depuis 1.6 le nom du champ via @SourcePropertyName String ou @TargetPropertyName String. Cela permet une seule condition générique qui décide selon le nom du champ.@SourceParameterCondition marque une condition qui porte sur le paramètre source entier, pas sur un champ. Utile pour ignorer un sous-objet vide.hasXxx() du bean source (style Protobuf) comme presence check, sans annotation.⚡ Interaction avec @MappingTarget
Quand la condition renvoie false sur une méthode de mise à jour, nullValuePropertyMappingStrategy s'applique. Avec IGNORE, le champ cible n'est pas touché. Avec le défaut SET_TO_NULL, il est mis à null. Une @Condition sans IGNORE sur un PATCH efface donc les champs qu'elle voulait protéger.
MapStruct sait construire un record par son constructeur canonique. Il détecte aussi les builders (Lombok @Builder, Immutables, AutoValue) via une méthode statique builder() et une méthode build(). Pour les classes sans setter, il cherche un constructeur avec les bons paramètres, ou celui annoté @Default.
@BeforeMapping, @AfterMapping, @Context@Mapper
public abstract class OrderMapper {
public abstract OrderDto toDto(Order order, @Context Locale locale); // ✓ @Context : passé, jamais mappé
@AfterMapping
protected void computeLabel(Order order, @MappingTarget OrderDto.Builder dto, @Context Locale locale) {
dto.label(LabelFormatter.format(order, locale));
}
}javaUne classe abstraite permet d'injecter des dépendances et de garder de l'état. Les hooks sont appelés dans le code généré, dans l'ordre : @BeforeMapping, mapping, @AfterMapping.
@Named et qualifiedByName@Mapper
public interface ProductMapper {
@Mapping(target = "price", source = "priceCents", qualifiedByName = "centsToEuros")
ProductDto toDto(Product product);
@Named("centsToEuros")
default BigDecimal centsToEuros(long cents) {
return BigDecimal.valueOf(cents, 2);
}
}javaSans @Named, une méthode default qui convertit long → BigDecimal serait appliquée à tous les champs long → BigDecimal du mapper. Le qualifier rend le choix explicite.
Un mapper réel a vite dix méthodes. Beaucoup se ressemblent. Cette section montre les outils pour ne pas répéter les règles, et pour gérer les cas que le mapping simple ne couvre pas.
@InheritInverseConfigurationProblème : toDto a trois @Mapping. Pour toEntity, il faut les mêmes, mais avec source et target inversés. On les recopie, et un jour on en oublie une.
Solution : @InheritInverseConfiguration lit les @Mapping d'une méthode qui va dans l'autre sens, et les retourne.
@Mapper
public interface OrderMapper {
@Mapping(target = "email", source = "customerEmail")
@Mapping(target = "total", source = "totalAmount")
OrderDto toDto(Order order);
@InheritInverseConfiguration // ✓ email → customerEmail, total → totalAmount
@Mapping(target = "id", ignore = true) // on peut ajouter des règles par dessus
Order toEntity(OrderDto dto);
}javaCe qui est inversé : les source / target. Ce qui ne l'est pas : constant, expression, ignore. Une constante n'a pas de sens à l'envers. Un ignore sur toDto ignore un champ du DTO, ce n'est pas un champ de l'entité. Il faut donc redéclarer ces cas.
Il existe aussi @InheritConfiguration. Elle copie les règles d'une méthode qui va dans le même sens. Cas typique : la méthode update hérite des @Mapping de la méthode create.
@Mapping(target = "id", ignore = true)
@Mapping(target = "email", source = "customerEmail")
Order toEntity(OrderDto dto);
@InheritConfiguration(name = "toEntity") // ✓ mêmes règles, pas de copier-coller
void update(OrderDto dto, @MappingTarget Order order);javaQuand il n'y a qu'une méthode candidate, name est facultatif. Si plusieurs méthodes correspondent, il faut la nommer, sinon erreur de compilation.
@BeanMapping(ignoreByDefault = true)Par défaut, MapStruct essaie de mapper tous les champs de la cible. Avec unmappedTargetPolicy = ERROR, il faut donc écrire ignore = true sur chaque champ qu'on ne veut pas.
Parfois c'est l'inverse qu'on veut. On a une entité avec trente champs. Le PATCH n'en touche que deux. Écrire vingt-huit ignore est absurde.
ignoreByDefault = true retourne la règle : rien n'est mappé, sauf ce qui a un @Mapping explicite.
@BeanMapping(ignoreByDefault = true)
@Mapping(target = "email", source = "email") // ✓ seuls ces deux champs sont copiés
@Mapping(target = "phone", source = "phone")
void updateContact(ContactPatchDto dto, @MappingTarget Customer customer);java@BeanMapping porte d'autres options utiles au niveau de la méthode : nullValuePropertyMappingStrategy, qualifiedByName (pour appliquer une méthode @Named à toute la méthode), resultType (choisir la sous-classe à instancier).
@SubclassMappingProblème : Animal a deux sous-classes, Dog et Cat. On veut AnimalDto toDto(Animal animal). Mais un Dog doit devenir un DogDto, avec ses champs propres. MapStruct ne le sait pas tout seul : il ne voit que le type déclaré, Animal.
@SubclassMapping (depuis 1.5) déclare la correspondance par sous-classe. Le code généré teste le type réel avec instanceof.
@Mapper
public interface AnimalMapper {
@SubclassMapping(source = Dog.class, target = DogDto.class)
@SubclassMapping(source = Cat.class, target = CatDto.class)
AnimalDto toDto(Animal animal);
DogDto toDogDto(Dog dog); // ✓ utilisées par le routage
CatDto toCatDto(Cat cat);
}javaCode généré :
if ( animal instanceof Dog ) {
return toDogDto( (Dog) animal );
}
else if ( animal instanceof Cat ) {
return toCatDto( (Cat) animal );
}
else {
throw new IllegalArgumentException( "Not all subclasses are supported for this mapping. Missing for " + animal.getClass() );
}javaCe qu'il faut savoir :
@SubclassMapping compte. Si Puppy extends Dog, déclarer Puppy avant Dog, sinon le instanceof Dog attrape tout.sealed (Java 17), subclassExhaustiveStrategy = COMPILE_ERROR sur @BeanMapping transforme cette exception en erreur de compilation. C'est la seule façon de rendre le routage sûr au build.@EnumMappingCas fréquent : un enum interne ORDER_CREATED, ORDER_SHIPPED et un enum public CREATED, SHIPPED. Écrire un @ValueMapping par constante marche, mais c'est long et fragile.
@EnumMapping (depuis 1.5) applique une règle sur le nom.
@EnumMapping(nameTransformationStrategy = MappingConstants.STRIP_PREFIX, configuration = "ORDER_")
PublicStatus toPublic(InternalStatus status); // ORDER_CREATED → CREATEDjavaStratégies disponibles : PREFIX, SUFFIX (ajouter), STRIP_PREFIX, STRIP_SUFFIX (retirer), CASE (upper, lower, capital). MapStruct vérifie toujours à la compilation que chaque constante source a une cible après transformation.
@ObjectFactoryPar défaut, MapStruct crée la cible avec new. Parfois on veut décider soi-même comment l'objet est obtenu.
Exemple : si le DTO a un id, on veut l'entité existante depuis la base, pas un objet neuf. Une méthode annotée @ObjectFactory qui renvoie le type cible est appelée à la place du new.
@Mapper(componentModel = "spring")
public abstract class CustomerMapper {
@Autowired
protected CustomerRepository repository;
@ObjectFactory
protected Customer resolve(CustomerDto dto) {
if ( dto.id() != null ) {
return repository.findById( dto.id() ).orElseGet( Customer::new ); // ✓ objet existant
}
return new Customer();
}
public abstract Customer toEntity(CustomerDto dto); // utilise resolve() au lieu de new
}javaCe qu'il faut savoir :
@Context ou un @TargetType Class<T> pour une factory générique.@MappingTarget, la factory n'est pas appelée : l'objet est déjà fourni.@DecoratedWith@AfterMapping permet d'ajouter du code après le mapping. Mais parfois on veut plus : faire quelque chose avant et après, ou remplacer complètement une méthode tout en gardant les autres générées.
@DecoratedWith désigne une classe abstraite qui implémente le mapper. MapStruct injecte le mapper généré dans le décorateur. Les méthodes que le décorateur ne redéfinit pas sont déléguées au code généré.
@Mapper(componentModel = "spring")
@DecoratedWith(OrderMapperDecorator.class)
public interface OrderMapper {
OrderDto toDto(Order order);
List<OrderDto> toDtos(List<Order> orders);
}
public abstract class OrderMapperDecorator implements OrderMapper {
@Autowired
@Qualifier("delegate") // ✓ le mapper généré, nommé "delegate" par MapStruct
private OrderMapper delegate;
@Override
public OrderDto toDto(Order order) {
OrderDto dto = delegate.toDto( order ); // 1. mapping généré
return dto.withLabel( LabelFormatter.format( order ) ); // 2. logique ajoutée
}
// toDtos n'est pas redéfini : délégué tel quel
}javaAvec componentModel = "spring", le décorateur devient le bean @Primary. C'est lui qu'on injecte partout, sans changer le code appelant.
Règle simple pour choisir :
@AfterMapping.@DecoratedWith.| Outil | Ce que ça fait | Quand |
|---|---|---|
@IterableMapping / @MapMapping |
Options sur les éléments d'une collection : dateFormat, qualifiedByName, elementTargetType |
Une List<LocalDate> vers List<String> avec un format |
nullValueMappingStrategy = RETURN_DEFAULT |
Renvoyer un objet vide au lieu de null si la source est null |
Une liste vide plutôt qu'une liste null dans une réponse API |
mappingControl = DeepClone.class |
Forcer une copie profonde des sous-objets, sans réutiliser les instances | Un mapper Order → Order pour dupliquer une commande |
@TargetType Class<T> |
Passer le type cible à une méthode générique du mapper | Une méthode <T> T findById(Long id, @TargetType Class<T> type) |
implementationName / implementationPackage |
Nommer et placer la classe générée | Une convention de nommage imposée |
-Amapstruct.suppressGeneratorTimestamp=true |
Retirer la date du commentaire @Generated |
Builds reproductibles, diffs propres |
🔑 Conclusion clé
Les règles de mapping doivent être écrites une seule fois. @InheritInverseConfiguration et @InheritConfiguration évitent le copier-coller. @SubclassMapping, @ObjectFactory et @DecoratedWith couvrent les cas que le mapping plat ne sait pas faire, sans revenir au code manuel.
C'est la partie qui distingue un usage sérieux d'un usage naïf. MapStruct a des défauts permissifs. Il faut les durcir.
| Option | Défaut | Valeur recommandée | Effet |
|---|---|---|---|
unmappedTargetPolicy |
WARN |
ERROR |
Un champ cible sans source bloque le build. Oblige à écrire ignore = true consciemment. |
unmappedSourcePolicy |
IGNORE |
WARN |
Signale un champ source oublié (utile pour les DTO qu'on rétrécit). |
nullValuePropertyMappingStrategy |
SET_TO_NULL |
IGNORE sur les méthodes @MappingTarget |
Ne pas écraser avec null en update. |
nullValueCheckStrategy |
ON_IMPLICIT_CONVERSION |
ALWAYS si les setters cibles refusent null |
Ajoute if (x != null) avant chaque set. |
collectionMappingStrategy |
ACCESSOR_ONLY |
ADDER_PREFERRED pour les entités JPA bidirectionnelles |
Utilise addItem(item) au lieu de setItems(list), ce qui maintient la owning side. |
injectionStrategy |
FIELD |
CONSTRUCTOR |
Injection par constructeur, testable sans Spring. |
La façon propre de les partager : un @MapperConfig.
@MapperConfig(
componentModel = MappingConstants.ComponentModel.SPRING,
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
unmappedTargetPolicy = ReportingPolicy.ERROR,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS
)
public interface CentralMapperConfig {}
@Mapper(config = CentralMapperConfig.class) // ✓ hérite tout, peut surcharger
public interface OrderMapper { ... }java🔑 Règle
unmappedTargetPolicy = ERROR dès le premier jour. Chaque champ ignoré doit être ignoré explicitement. Le jour où un champ est ajouté à l'entité et oublié dans le DTO, le build casse au lieu de renvoyer un null en prod.
Avec componentModel = "spring", MapperImpl est annoté @Component. On l'injecte comme n'importe quel bean.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderMapper orderMapper; // ✓ injection de OrderMapperImpl
private final OrderRepository orderRepository;
public OrderDto get(Long id) {
return orderRepository.findById(id)
.map(orderMapper::toDto)
.orElseThrow();
}
}javaLes mappers déclarés dans uses sont injectés aussi. Un mapper peut donc dépendre d'un autre, ou d'un Repository pour résoudre un id en entité (à utiliser avec parcimonie, cela cache des accès base dans un mapper).
En test unitaire, sans contexte Spring, Mappers.getMapper(OrderMapper.class) ne fonctionne pas avec le component model Spring. Deux options : instancier new OrderMapperImpl() directement, ou utiliser @SpringBootTest restreint avec @Import(OrderMapperImpl.class).
MapStruct Spring Extensions (projet séparé, version 2.0.0, Java 17 et Spring 6 minimum) va plus loin. Il génère des Converter<S, T> Spring et un ConversionService agrégé, pour appeler conversionService.convert(order, OrderDto.class) sans connaître le mapper. Utile dans un gros projet, superflu ailleurs.
⚡ Lazy loading et N+1
Un mapper Order → OrderDto qui mappe order.getItems() déclenche le chargement de la collection. Dans une boucle toDtos(List<Order>), c'est un N+1. Hors transaction, c'est une LazyInitializationException. Le mapper ne sait pas ce qui est chargé. Il faut soit charger explicitement (JOIN FETCH, @EntityGraph), soit ne pas exposer la collection dans le DTO, soit mapper depuis une projection. Voir jpa-hibernate-relations-mapping-collections pour le côté Hibernate.
⚡ Cycles d'objets
Order → items → Item.order → Order... Sans précaution, le mapper généré boucle jusqu'au StackOverflowError. La solution officielle est un @Context qui mémorise les instances déjà mappées (CycleAvoidingMappingContext dans les exemples MapStruct). La solution plus saine est de ne pas mapper la référence retour dans le DTO : @Mapping(target = "order", ignore = true) sur ItemDto.
⚡ @MappingTarget avec des collections
Par défaut, MapStruct fait target.getItems().clear() puis addAll(...) si le getter renvoie une collection non null. Avec JPA et orphanRemoval = true, cela supprime et recrée toutes les lignes enfants à chaque update. Si ce n'est pas voulu, il faut ignorer la collection dans la méthode update et la gérer à la main dans le service.
Autres points à connaître :
getFullName() sans champ derrière est vue comme une propriété source. Elle peut créer un mapping non voulu.default avec la même signature de types font échouer le build avec Ambiguous mapping methods found. Résolution avec @Named ou @Qualifier custom.Impl n'apparaissent pas et les tests échouent avec ClassNotFoundException.new XxxMapperImpl(), sur un objet source complet. Cela documente le contrat et casse quand une règle change.La 1.7 est en beta (1.7.0.Beta2, juin 2026). Nouveautés notables :
Optional natif : Optional<String> en source est déballé, en cible est enveloppé, sans méthode custom.@Nullable / @NonNull JSpecify sont respectées dans le code généré.@Mapping(target = ..., ignore = true) répété est remplaçable par une seule annotation listant les champs.switch expressions pour les enums, diamant, multi-catch.Pour un projet existant, rester en 1.6.3 jusqu'à la 1.7.0.Final. Les betas sont stables mais l'API peut bouger.
| Critère | Mapping manuel | MapStruct | ModelMapper / Dozer |
|---|---|---|---|
| Moment de résolution | Écriture | Compilation | Exécution (réflexion) |
| Performance | Optimale | Identique au manuel | 10× à 100× plus lent |
| Erreur de champ oublié | Silencieuse | Build cassé (ERROR) |
Silencieuse, null en prod |
| Lisibilité du code | Bonne mais verbeuse | Interface courte, Impl lisible |
Configuration opaque |
| Débogage | Simple | Simple (breakpoint dans Impl) |
Difficile |
| Dépendance runtime | Aucune | Aucune (annotations seulement) | Bibliothèque chargée |
| Courbe d'apprentissage | Nulle | Faible | Faible au début, piégeuse après |
Quand choisir :
⚡ TL;DR — chaque concept en une ligne
Annotation processor
✓ Génère une classe Java lisible au build, aucun coût ni dépendance à l'exécution.
⚠ Exige que le processor soit dans annotationProcessorPaths, et que l'IDE active l'annotation processing.
@Mapping
✓ Renomme, ignore, fixe une constante, suit un chemin imbriqué, avec vérification à la compilation.
⚠ expression, defaultExpression et conditionExpression sont du texte brut, non vérifié, à limiter aux cas triviaux.
target = "." / source = "."
✓ Le point désigne l'objet entier : aplatir un sous-objet dans la cible, ou construire un sous-objet cible depuis toute la source.
⚠ Avec source = ".", la méthode est choisie par type de retour : deux champs cibles du même type exigent @Named + qualifiedByName.
Collections et imbriqué
✓ Une méthode pour l'élément suffit, MapStruct compose List, Set, Map et les sous-objets via uses.
⚠ Les sous-mappings implicites sont générés en privé sans contrôle : déclarer une méthode dès qu'il y a une règle.
@ValueMapping / @EnumMapping
✓ Mappe enum vers enum, par constante ou par règle sur le nom, avec vérification exhaustive au build.
⚠ Sans ANY_REMAINING, une valeur inconnue lève une exception à l'exécution.
@MappingTarget
✓ Modifie un objet déjà en mémoire (l'entité managed) au lieu d'en créer un, compatible avec le dirty checking JPA.
⚠ Défaut SET_TO_NULL : écrase les champs avec null, il faut nullValuePropertyMappingStrategy = IGNORE pour un PATCH.
@Condition
✓ Remplace le test != null par une méthode à soi pour décider si un champ est copié (chaîne vide, valeur sentinelle).
⚠ Choisie par type : une condition sur String s'applique à tous les String du mapper, sauf ciblage par conditionQualifiedByName.
@InheritInverseConfiguration / @InheritConfiguration
✓ Réutilise les @Mapping d'une autre méthode, inversés ou tels quels, sans copier-coller.
⚠ constant, expression et ignore ne sont pas inversés : à redéclarer sur la méthode inverse.
@SubclassMapping
✓ Route vers le bon sous-mapper selon le type réel de la source (instanceof).
⚠ Un sous-type non déclaré lève une exception à l'exécution, sauf classe sealed + COMPILE_ERROR.
@ObjectFactory / @DecoratedWith
✓ Contrôlent la création de la cible et l'enveloppe des méthodes générées, sans repasser en code manuel.
⚠ Une factory qui fait un findById cache une requête SQL dans un mapper.
unmappedTargetPolicy = ERROR
✓ Tout champ cible non alimenté casse le build, plus de null oublié en prod.
⚠ Défaut WARN : personne ne lit les warnings, il faut forcer ERROR.
Lombok
✓ Compatible, @Builder et @Data sont détectés.
⚠ Exige lombok-mapstruct-binding et Lombok listé avant le processor MapStruct.
Component model Spring
✓ Impl devient un @Component injectable, uses est injecté aussi.
⚠ Mappers.getMapper() ne marche plus, en test on fait new XxxMapperImpl().
🎓 À retenir
@MapperConfig central évite que chaque développeur oublie ERROR, CONSTRUCTOR ou IGNORE. Un mapper sans config = est un signal en revue de code.getDisplayName() sans champ sera mappé si un champ cible porte le même nom. Renommer en computeDisplayName() ou displayName() si ce n'est pas voulu.clear() + addAll() sur une collection cible interagit mal avec orphanRemoval. Ignorer les collections dans les méthodes update d'entités JPA, les synchroniser dans le service.default sans @Named est globale au mapper : elle s'applique à toutes les paires de types compatibles. Qualifier dès qu'il existe deux conversions possibles pour les mêmes types.@Condition à false ne veut pas dire "ne rien faire" : sur une méthode update, c'est nullValuePropertyMappingStrategy qui décide ensuite. Sans IGNORE, le champ est mis à null.source = "." + @Condition est le bon duo pour une règle multi-champs : la condition reçoit la source entière et peut consulter plusieurs champs avant de décider si le sous-objet est construit.@SubclassMapping compte : la sous-classe la plus spécifique en premier, sinon le instanceof du parent attrape tout.@BeanMapping(ignoreByDefault = true) est le bon outil pour un PATCH ciblé : deux @Mapping explicites valent mieux que vingt-huit ignore.@SubclassMapping.target = ".")Converter et ConversionService Spring, 2.0.0 en Java 17 / Spring 6mapstruct-mapping-with-cycles pour le CycleAvoidingMappingContext, mapstruct-lombok pour le binding, mapstruct-decorator, mapstruct-object-factory et mapstruct-nested-bean-mappings