Once you accept that entities and DTOs are different types (lesson 11), something has to convert between them. Hand-written mapping code is tedious, and its failure mode is quiet: someone adds a field to both classes, forgets one line in the mapper, and it is always null.
MapStruct generates that code at compile time. No reflection, no runtime cost, and a mapping mistake becomes a compile error.
Setup — and the ordering that matters
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency><plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- Order is load-bearing. Lombok must run before MapStruct, and
lombok-mapstruct-binding is what lets MapStruct see the getters
and setters Lombok generates. Get this wrong and MapStruct still
compiles, but the generated mappers map nothing. -->
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>⚠️ Read that comment before you debug anything. This is the classic MapStruct problem and it is horrible precisely because the build succeeds. MapStruct runs before Lombok has generated the accessors, sees a class with no getters, and generates a mapper that maps nothing. Every DTO comes back with null fields, and there is no error anywhere to search for.
Three things must be true: Lombok first, then lombok-mapstruct-binding, then
mapstruct-processor. Skipping the binding is the same bug.
The mapper is an interface
/**
* The single mapper for every entity ↔ DTO conversion.
*
* <p>MapStruct generates the implementation at COMPILE time — open
* {@code target/generated-sources/annotations} to read the plain Java it writes. No reflection, no
* runtime cost, and a mapping mistake is a compile error rather than a surprise null.
*/
@Mapper(
componentModel = "spring",
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface EntityDTOMapper {
@Mapping(target = "id", source = "publicId")
ProductDTO mapProductToProductDTO(Product product);
List<ProductDTO> mapProductsToProductDTOs(List<Product> products);
}componentModel = "spring" makes the generated class a @Component, so you
inject the interface like any other bean. Without it you get a static INSTANCE field
instead.
The List method needs no annotation at all — MapStruct sees it already knows how to
map the element type and writes the loop.
Go and read the generated code. target/generated-sources/annotations
contains plain Java: a class calling getters and setters, exactly what you would have written. That
is the strongest argument for MapStruct over a reflection-based mapper — there is no mystery, just
code you did not have to type.
Renaming a field
@Mapping(target = "id", source = "publicId")
ProductDTO mapProductToProductDTO(Product product);Everything matching by name is mapped automatically; @Mapping handles the rest. The
comment on this one explains why it is the most important line in the file:
/**
* <p><b>Note {@code id <- publicId} on every mapping.</b> That one line is what keeps the numeric
* primary key server-side: there is no path by which the BIGINT can reach a client.
*/The reverse direction is a security boundary
/**
* The inverse direction. Everything the server owns is ignored, because it must never be
* settable from a request body — this is the mass-assignment guard.
*/
@Mapping(target = "id", ignore = true)
@Mapping(target = "publicId", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
@Mapping(target = "deleted", ignore = true)
@Mapping(target = "sizes", ignore = true)
Product mapProductCreateDTOToProduct(ProductCreateDTO dto);Every ignore = true is a field a client must not be able to set. Without them,
MapStruct maps by name — so if ProductCreateDTO ever gained a deleted field,
the mapper would faithfully copy it and a request could undelete a product.
The best protection is what is absent. As the mapper's own comment puts it: no
mapping produces a password hash, because UserDTO has no such field. MapStruct maps by
matching names, so the hash cannot leak through this mapper even by accident. Designing the DTO
correctly is a stronger guarantee than remembering an annotation.
Two settings worth understanding
unmappedTargetPolicy controls what happens when a target field has no
source:
| Value | Effect |
|---|---|
ERROR | build fails — safest, noisiest |
WARN | the default |
IGNORE | silent |
The pizza API uses IGNORE because most of its DTOs are deliberately narrower than
their entities and WARN produced pages of noise on every build. That is a real trade:
ERROR would catch a genuinely forgotten field. If you can live with the annotations,
ERROR is the stronger choice.
nullValueCheckStrategy = ALWAYS makes the generated code null-check
before calling a getter on a nested object, rather than assuming it is present.
Nested objects and expressions
// Reach into a nested object with dotted paths
@Mapping(target = "productName", source = "product.name")
@Mapping(target = "crustName", source = "crust.name")
OrderItemDTO mapOrderItemToOrderItemDTO(OrderItem item);
// Compute something MapStruct cannot infer
@Mapping(target = "displayName",
expression = "java(user.getFullName() != null ? user.getFullName() : user.getEmail())")
UserDTO mapUserToUserDTO(User user);
// A constant
@Mapping(target = "source", constant = "API")
OrderDTO mapOrder(CustomerOrder order);expression is an escape hatch and should stay small. Once the logic is more than a
ternary it belongs in a default method on the interface, where it can be read and
tested:
default String displayName(User user) {
if (user == null) {
return null;
}
return user.getFullName() == null || user.getFullName().isBlank()
? user.getEmail()
: user.getFullName();
}MapStruct finds default methods and uses them automatically when the types line
up.
One mapper or many?
The pizza API has exactly one, EntityDTOMapper, for every conversion in the
application. The reasoning is in lesson 4: with one central dto/ package, one mapper has
an obvious home, whereas per-feature DTOs give you per-feature mappers and more places for the
conventions to drift.
The cost is a long file. The alternative — ProductMapper, OrderMapper,
UserMapper — is more navigable and needs uses = {…} so mappers can call each
other. Either works; the failure is having both styles at once.
When something maps to null
- Check the annotation-processor order first. It is this, more often than anything else.
- Read the generated implementation in
target/generated-sources/annotations. If the setter is missing there, the processor never saw the getter. ./mvnw clean compile. Stale generated sources are real, and the pizza API's own docs warn about stale classes producing behaviour that does not match the source.
What to take from this
- Processor order: Lombok, binding, MapStruct. Wrong order compiles fine and maps nothing.
componentModel = "spring"so the mapper is an injectable bean.ignore = trueon every server-owned field in the request direction — that is your mass-assignment guard.- A field absent from the DTO cannot leak. Design beats vigilance.
- Read the generated code. It is ordinary Java.
Next: Lombok — including the two annotations that will bite you on a JPA entity.