Every layered Java service copies data between shapes: JPA entities to API DTOs, Kafka events to domain objects, request bodies to commands. Written by hand, that code is long, dull and quietly wrong; someone adds a field to the entity and forgets the DTO, and nothing fails until a customer notices a missing value. Reflection-based mappers (ModelMapper, Dozer, Apache BeanUtils) remove the typing but move the errors to run time and add reflection cost to every call.
MapStruct takes a third route. You declare a mapper as a Java interface with annotations; an annotation processor runs inside javac and writes a plain implementation class that calls getters and setters. If a mapping is ambiguous or a target property has no source, the build can fail. If it compiles, what runs is ordinary method calls you can read in target/generated-sources. This article explains how that works, how to read and control the generated code, how to handle nested objects, custom conversions, partial updates, Lombok and Spring, how to test mappers, and where MapStruct is the wrong tool. Versions: 1.6.3 is the current stable release; 1.7.0 is in beta as of 2026 and is noted where it matters.
How a compile-time mapper works
Annotation processors run during compilation, in rounds. The MapStruct processor looks for types annotated with @Mapper, and for each abstract method it inspects the parameter and return types through the compiler's type model (javax.lang.model). It matches target properties to source properties by name, following getters, setters, constructors and builders. Then it generates Java source for the implementation, which the compiler compiles in the next round. Nothing is discovered at run time. There is no scanning, no proxy and no reflection, and the mapper costs what the equivalent hand-written code would cost.
Two consequences shape everything else. Errors arrive at compile time, so you can make the build refuse incomplete mappings. And because the processor reads other processors' output, ordering matters when Lombok generates the getters MapStruct needs. The general mechanics of retention, processors and rounds are covered in Java annotations in depth; this article stays on mapping.
A worked example: entity to API record
An Order entity holds a Customer, a list of lines, an amount in cents, an enum status and an Instant. The API returns a flat record with the customer's name and email, a decimal total, the status as a string and an ISO-8601 timestamp. Java records work as targets because MapStruct maps into constructor parameters; see Java records for why they make good DTOs.
// Entity side (JPA or domain)
public class Order {
private Long id;
private Customer customer; // has getName(), getEmail()
private List<OrderLine> lines;
private long totalCents;
private OrderStatus status; // NEW, PAID, SHIPPED, CANCELLED
private Instant createdAt;
// getters and setters
}
// API side
public record OrderDto(Long id, String customerName, String customerEmail,
List<OrderLineDto> lines, BigDecimal total,
String status, String createdAt) {}@Mapper(componentModel = MappingConstants.ComponentModel.SPRING,
uses = MoneyMapper.class,
unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface OrderMapper {
@Mapping(target = "customerName", source = "customer.name")
@Mapping(target = "customerEmail", source = "customer.email")
@Mapping(target = "total", source = "totalCents", qualifiedByName = "centsToAmount")
OrderDto toDto(Order order);
List<OrderDto> toDtos(List<Order> orders); // generated loop reusing toDto
OrderLineDto toDto(OrderLine line); // used for the nested list
}
@Component
public class MoneyMapper {
@Named("centsToAmount")
public BigDecimal centsToAmount(long cents) {
return BigDecimal.valueOf(cents, 2);
}
}Read the mapper line by line. source = "customer.name" flattens a nested property. qualifiedByName picks one specific conversion method from MoneyMapper by its @Named value, which matters as soon as you have two methods with the same signature (cents to amount, and cents to a display string, for example). The enum-to-string and Instant-to-string conversions are built in; the latter produces ISO-8601 text. The list method gets an implementation that loops and calls the element mapper, and unmappedTargetPolicy = ReportingPolicy.ERROR turns any target property without a source into a compile error rather than the default warning.
Reading the generated code
Open the generated class after every non-trivial change. It is the documentation of what MapStruct actually decided, and it is where you find surprises such as a conversion you did not expect, a missing null check or a method picked by type when you meant another.
// Abridged shape of target/generated-sources/annotations/.../OrderMapperImpl.java.
// Exact output varies by MapStruct version.
@Component
public class OrderMapperImpl implements OrderMapper {
@Autowired
private MoneyMapper moneyMapper;
@Override
public OrderDto toDto(Order order) {
if ( order == null ) {
return null;
}
String customerName = null;
String customerEmail = null;
// ...
customerName = orderCustomerName( order ); // null-safe nested getter
customerEmail = orderCustomerEmail( order );
total = moneyMapper.centsToAmount( order.getTotalCents() );
lines = orderLineListToOrderLineDtoList( order.getLines() );
if ( order.getStatus() != null ) {
status = order.getStatus().name();
}
// ...
return new OrderDto( id, customerName, customerEmail, lines, total, status, createdAt );
}
private String orderCustomerName(Order order) {
Customer customer = order.getCustomer();
if ( customer == null ) {
return null;
}
return customer.getName();
}
// ...
}Notice the null handling: nested access goes through generated private helpers that return null when an intermediate object is null, so a missing customer yields a null name rather than a NullPointerException. Primitive targets cannot hold null, so a null wrapper source either stays unmapped or needs a defaultValue. With the Spring component model the class is a @Component and its collaborators are injected; field injection is the default, and you can switch to constructor injection with injectionStrategy = InjectionStrategy.CONSTRUCTOR, which makes the mapper easier to build in tests.
Controlling the mapping
- Rename and flatten:
@Mapping(target, source)with dotted paths in either direction.target = "customer.name"unflattens into a nested object. - Ignore:
@Mapping(target = "id", ignore = true)for properties the source must never set. UnderReportingPolicy.ERRORan ignore is a deliberate, reviewable statement. - Constants and defaults:
constant = "API"always sets a value;defaultValue = "0"applies only when the source is null. - Expressions:
expression = "java(...)"embeds Java source as a string. The compiler still checks it once generated, but refactoring tools will not see it; prefer a named method. - Custom methods: any method on the mapper or in a
usesclass with matching types becomes a candidate. Add@NamedplusqualifiedByNameas soon as the choice could be ambiguous. - Enums: same-named constants map automatically;
@ValueMappinghandles renamed or collapsed constants, and an unmapped constant is a compile error. - Shared configuration: a
@MapperConfiginterface referenced byconfig =sets policies once for many mappers;@InheritInverseConfigurationderives the reverse mapping from the forward one. - Hooks and context:
@BeforeMappingand@AfterMappingmethods run around the generated body; a@Contextparameter passes things like a locale or a cycle-tracking map through nested calls without being mapped itself.
Updating existing objects: PATCH without data loss
Mapping into a new object is the easy case. Updating a managed JPA entity from a partial request is where hand-written code usually goes wrong, by overwriting fields with nulls or letting the client set the primary key. MapStruct handles this with an update method: return void, annotate the target parameter with @MappingTarget, and choose what a null source means.
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING,
unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CustomerPatchMapper {
// PATCH semantics: a null field in the request means "leave it alone"
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
@Mapping(target = "id", ignore = true) // never from the client
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "version", ignore = true) // optimistic-lock column
void apply(CustomerPatch patch, @MappingTarget Customer entity);
@AfterMapping
default void normalise(@MappingTarget Customer entity) {
if (entity.getEmail() != null) {
entity.setEmail(entity.getEmail().trim().toLowerCase(Locale.ROOT));
}
}
}NullValuePropertyMappingStrategy.IGNORE leaves the target's value alone when the source property is null, which is PATCH semantics; the default, SET_TO_NULL, gives PUT semantics. The explicit ignores keep identifiers, audit columns and the optimistic-lock version out of the client's reach, and ReportingPolicy.ERROR forces the next developer who adds an entity column to decide whether the patch may touch it. Collections need care: by default MapStruct replaces the target collection's contents, which with JPA orphan removal can delete child rows. For child collections, write an explicit merge method rather than trusting the default.
Build setup: Lombok, Spring and processor options
MapStruct needs two artifacts: mapstruct (the annotations) on the compile classpath and mapstruct-processor on the annotation processor path. If you use Lombok, the processor must see Lombok's generated getters, setters and builders. Put Lombok first and add lombok-mapstruct-binding, which tells MapStruct to wait for Lombok's round.
<!-- Maven: processors on the processor path, Lombok first, then the binding, then MapStruct -->
<properties>
<org.mapstruct.version>1.6.3</org.mapstruct.version>
</properties>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<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>
<compilerArgs>
<arg>-Amapstruct.defaultComponentModel=spring</arg>
<arg>-Amapstruct.unmappedTargetPolicy=ERROR</arg>
</compilerArgs>
</configuration>
</plugin>In Gradle the equivalent is an annotationProcessor dependency for each processor in the same order; see Gradle in depth for configurations. Setting mapstruct.defaultComponentModel=spring once saves repeating componentModel on every mapper, and Spring Boot then picks the mappers up through component scanning; see Spring Boot in depth. IDEs must have annotation processing enabled, or the generated classes will appear missing in the editor while the command-line build passes.
Testing mappers
A mapper is pure code, so test it as a unit without a Spring context. The value is not in testing MapStruct; it is in pinning the decisions you made: the flattening paths, the custom conversions, null behaviour and the PATCH rules.
class OrderMapperTest {
// No Spring context needed: instantiate the generated class directly.
private final OrderMapper mapper = new OrderMapperImpl();
@BeforeEach
void wire() throws Exception {
// with componentModel=spring, inject collaborators by hand or use constructor injection
var f = OrderMapperImpl.class.getDeclaredField("moneyMapper");
f.setAccessible(true);
f.set(mapper, new MoneyMapper());
}
@Test
void mapsNestedCustomerAndMoney() {
var o = new Order();
o.setId(7L);
o.setCustomer(new Customer("Ada", "ada@example.com"));
o.setTotalCents(12345);
o.setStatus(OrderStatus.PAID);
var dto = mapper.toDto(o);
assertEquals("Ada", dto.customerName());
assertEquals(new BigDecimal("123.45"), dto.total());
assertEquals("PAID", dto.status());
}
@Test
void nullCustomerDoesNotThrow() {
var o = new Order();
assertNull(mapper.toDto(o).customerName());
}
}Reflective field injection in a test is a smell that tells you to switch to constructor injection, after which the test becomes new OrderMapperImpl(new MoneyMapper()). Add one round-trip test per bidirectional mapper (entity to DTO and back, compared field by field), and one test per update method proving that nulls do not overwrite and protected fields do not change. ReportingPolicy.ERROR is itself a test: it runs on every compile at no cost.
Failure modes
- Silent unmapped fields. The default policy is a warning that scrolls past in CI logs. Set
ERRORglobally, then add explicit ignores. - Ambiguous methods. Two candidate conversions with the same types fail compilation with an ambiguity error, or worse, a newly added helper changes which one is chosen. Qualify by name.
- Lombok ordering. Errors like "Unknown property in result type" for properties that plainly exist usually mean MapStruct ran before Lombok. Fix the processor path order and add the binding.
- Stale generated code. Incremental IDE builds sometimes keep an old implementation. A clean build settles it.
- Lazy-loading surprises. Mapping a JPA entity touches every getter the DTO needs, so a nested collection triggers lazy loads (N+1 queries) or a
LazyInitializationExceptionoutside a transaction. Fetch what the DTO needs before mapping. - Cycles. Bidirectional relationships (order to customer to orders) recurse forever. Break them in the DTO design or track visited objects with a
@Contextparameter. - Business logic creep. Pricing rules in
@AfterMappingare hard to find. Mappers should change shape, not make decisions.
Trade-offs and alternatives
| Approach | Errors found | Run-time cost | Best for |
|---|---|---|---|
| Hand-written mapping | Code review, tests | None extra | A handful of small mappings |
| MapStruct | Compile time (with ERROR policy) | None extra, plain calls | Many DTOs, layered services |
| Reflection mappers (ModelMapper and similar) | Run time | Reflection on every call | Prototypes, dynamic shapes |
| Records plus static factory methods | Compile time | None extra | Small immutable models |
MapStruct adds a build-time dependency and an extra concept for every new team member, and expressions in strings weaken refactoring. In return you get compile-time completeness checks and code you can read. If your models are dynamic, such as maps of attributes or JSON with unknown fields, a compile-time mapper cannot help, and reflection or a JSON tree is the honest tool. Looking ahead, the 1.7.0 betas add native Optional support, JSpecify nullness annotations and modernised generated code. They are betas, so pin 1.6.3 in production until 1.7.0 is final.
What to do next
- Add
mapstructandmapstruct-processor1.6.3, with Lombok andlombok-mapstruct-bindingearlier on the processor path if you use Lombok. - Set
-Amapstruct.unmappedTargetPolicy=ERRORand-Amapstruct.defaultComponentModel=spring(or your DI model) as compiler arguments. - Convert one hand-written mapper, open the generated implementation, and compare it line by line with the old code.
- Write update methods with
@MappingTargetandNullValuePropertyMappingStrategy.IGNOREfor PATCH endpoints, explicitly ignoring identifiers, audit fields and version columns. - Use constructor injection, unit-test each mapper without Spring, and add a round-trip test for each bidirectional pair.
- Make sure every JPA mapping happens inside a transaction with the needed associations fetched, and keep business rules out of mapping hooks.