MapStruct
MapStruct is a compile-time code generator for bean mappings. You declare a mapper interface and MapStruct writes the implementation during compilation — plain field-copying Java, no reflection. The result is as fast as hand-written code, fully type-safe, and any mismatch becomes a compile error rather than a runtime surprise.
Adding the dependency and processor
MapStruct needs two pieces: the runtime API on the classpath and the annotation processor that runs during compilation.
<properties>
<org.mapstruct.version>1.6.3</org.mapstruct.version>
</properties>
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
The processor is configured on the compiler plugin so the generated classes appear in target/generated-sources:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
Ordering with Lombok
If you use Lombok, the two processors compete: Lombok must generate getters and setters before MapStruct reads them. Add the lombok-mapstruct-binding so they run in the right order, and list Lombok first in the processor paths.
<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>0.2.0</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
Warning: Forgetting
lombok-mapstruct-bindingproduces confusing “property has no write accessor” errors, because MapStruct runs before Lombok has generated the setters. The binding is what makes the two cooperate.
Defining a mapper
Declare an interface and annotate it @Mapper. Setting componentModel = "spring" makes the generated implementation a Spring @Component you can inject like any other bean.
@Mapper(componentModel = "spring")
public interface ProductMapper {
ProductResponse toDto(Product product);
Product toEntity(CreateProductRequest request);
List<ProductResponse> toDtoList(List<Product> products);
}
MapStruct matches fields by name automatically, so id, name, and price need no configuration.
@Mapping for field renames and expressions
When names differ or a value is nested, use @Mapping. The source is read from the input, the target is written on the output.
@Mapper(componentModel = "spring")
public interface ProductMapper {
@Mapping(source = "category.name", target = "categoryName")
@Mapping(target = "createdAt", expression = "java(java.time.Instant.now())")
ProductResponse toDto(Product product);
@Mapping(target = "id", ignore = true)
@Mapping(target = "category", ignore = true)
Product toEntity(CreateProductRequest request);
}
source = "category.name"flattens the nested association intocategoryName.ignore = trueonid/categorytells MapStruct not to map server-managed fields from the request.
Nested and collection mapping
If a referenced mapper handles a sub-type, list it in uses and MapStruct delegates automatically. Collections are mapped element-by-element using the single-element method.
@Mapper(componentModel = "spring", uses = CategoryMapper.class)
public interface OrderMapper {
@Mapping(source = "items", target = "lineItems")
OrderResponse toDto(Order order); // List<Item> -> List<LineItemResponse>
}
MapStruct sees List<Item> -> List<LineItemResponse>, finds (or generates) an Item -> LineItemResponse mapping, and loops for you.
What the generated code looks like
MapStruct emits a normal class — readable, debuggable, no reflection:
@Component
public class ProductMapperImpl implements ProductMapper {
@Override
public ProductResponse toDto(Product product) {
if (product == null) {
return null;
}
String categoryName = null;
if (product.getCategory() != null) {
categoryName = product.getCategory().getName();
}
return new ProductResponse(
product.getId(),
product.getName(),
product.getPrice(),
categoryName);
}
}
Notice the automatic null-guards on the nested association.
Injecting the generated mapper
Because componentModel = "spring" registers it as a bean, inject it with constructor injection like any service.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repository;
private final ProductMapper mapper;
@Transactional(readOnly = true)
public ProductResponse findById(Long id) {
return mapper.toDto(repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id)));
}
}
MapStruct vs ModelMapper at a glance
| Aspect | MapStruct | ModelMapper |
|---|---|---|
| When mapping is resolved | Compile time | Runtime (reflection) |
| Performance | Native speed | Slower (reflection) |
| Mismatch detected | Compile error | Runtime / silent skip |
| Setup | Dependency + processor | Single dependency + @Bean |
Tip: Set
unmappedTargetPolicy = ReportingPolicy.ERRORon@Mapperto fail the build whenever a target field is left unmapped — turning silent gaps into compile errors.