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.

Advertisement

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.

MapStruct: mapping code is generated at compile time, not discovered at run timeOrderMapper.javainterface + @Mapperjavacannotation processing roundMapStruct processorreads types via javax.lang.modelOrderMapperImpl.javaplain getters and settersOrderMapperImpl.classcompiled with your codeRuntimeSpring bean or Mappers.getMapperCompile errorsunmapped target, ambiguityLombokmust run first (binding)sourceroundwritescompileloadpolicygetters/settersNo reflection at run time: if the mapping is wrong, the build fails; if it compiles, it is ordinary method calls.
The processor writes OrderMapperImpl during compilation; at run time only plain Java executes.

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.

Advertisement

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. Under ReportingPolicy.ERROR an 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 uses class with matching types becomes a candidate. Add @Named plus qualifiedByName as soon as the choice could be ambiguous.
  • Enums: same-named constants map automatically; @ValueMapping handles renamed or collapsed constants, and an unmapped constant is a compile error.
  • Shared configuration: a @MapperConfig interface referenced by config = sets policies once for many mappers; @InheritInverseConfiguration derives the reverse mapping from the forward one.
  • Hooks and context: @BeforeMapping and @AfterMapping methods run around the generated body; a @Context parameter 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 ERROR globally, 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 LazyInitializationException outside 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 @Context parameter.
  • Business logic creep. Pricing rules in @AfterMapping are hard to find. Mappers should change shape, not make decisions.

Trade-offs and alternatives

ApproachErrors foundRun-time costBest for
Hand-written mappingCode review, testsNone extraA handful of small mappings
MapStructCompile time (with ERROR policy)None extra, plain callsMany DTOs, layered services
Reflection mappers (ModelMapper and similar)Run timeReflection on every callPrototypes, dynamic shapes
Records plus static factory methodsCompile timeNone extraSmall 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

  1. Add mapstruct and mapstruct-processor 1.6.3, with Lombok and lombok-mapstruct-binding earlier on the processor path if you use Lombok.
  2. Set -Amapstruct.unmappedTargetPolicy=ERROR and -Amapstruct.defaultComponentModel=spring (or your DI model) as compiler arguments.
  3. Convert one hand-written mapper, open the generated implementation, and compare it line by line with the old code.
  4. Write update methods with @MappingTarget and NullValuePropertyMappingStrategy.IGNORE for PATCH endpoints, explicitly ignoring identifiers, audit fields and version columns.
  5. Use constructor injection, unit-test each mapper without Spring, and add a round-trip test for each bidirectional pair.
  6. Make sure every JPA mapping happens inside a transaction with the needed associations fetched, and keep business rules out of mapping hooks.
Key takeaway: MapStruct turns mapping declarations into ordinary Java at compile time, so mistakes become build errors and run-time cost matches hand-written code. Turn unmapped targets into errors, qualify custom conversions by name, use @MappingTarget with an explicit null strategy for updates, order Lombok before MapStruct, read the generated class, and keep mappers free of business logic.