Skip to main content

MapStruct with Spring Boot 4 and Java Records: DTO Mapping Done Right

A beginner-first walk through MapStruct on Spring Boot 4.1.2 with Java records as DTOs: how the generated mapper works, nested and collection mapping, null-value strategies, updating existing objects, and what @AfterMapping, @BeforeMapping and @ObjectFactory really do when the target is an immutable record. Every figure comes out of a companion project whose 19 tests write the transcripts, plus the compiler’s own error messages and an honestly labelled, indicative comparison with hand-written code and Dozer. Includes surprises: @MappingTarget on a record compiles to an empty method, and JDK 23+ no longer runs a processor that is only on the classpath.

Every Spring application ends up with the same chore: an entity with fourteen fields on one side, an API object with eleven on the other, and a method in between that copies one into the other. Written by hand it is boring, and boring code is where a renamed field quietly goes missing. A MapStruct Spring Boot setup replaces that method with one that is generated while you compile — and since Java 16 the API object is usually a record, which changes a few things about how that generation has to work. This article walks from “what is a mapper” to “why did my @ObjectFactory silently throw away every field”, in that order. Everything in it was run: the companion project is asmhatre/spring-boot-demo/mapstruct, whose 19 tests write the first sixteen transcripts under output/ and whose scripts capture the rest, including the compiler’s own error messages. If a claim here stops being true, that build goes red. It pairs with the older post on Dozer bean mapping, which maps by reflection at runtime. The middle of this article measures the difference, and labels the numbers honestly as indicative.
Versions. Spring Boot 4.1.2 (newest GA in Maven Central’s metadata; its directory entry is dated 2026-10-22, and 4.1.1, which the sibling modules use, is also GA), Spring Framework 7.0.10, Jackson 3.1.7 (tools.jackson), MapStruct 1.6.3 (newest GA, jar dated 2024-11-09 — 1.7.0.Beta2 is in the metadata too and is a beta, so it is not used), Dozer 7.0.0 (newest release in the metadata, test scope only), JDK 25 (Oracle 25.0.3, not Temurin), Maven 3.9. No Lombok. Generated sources were read from target/ and the processor jar was opened with javap rather than trusting the documentation.

MapStruct writes the copy-fields method for you, while you compile

Start with the smallest correct picture. You write an interface that says “turn a Customer into a CustomerDto”. You do not write the body. When javac compiles the project, a plug-in called an annotation processor reads your interface and writes a Java class that implements it — ordinary get/set/constructor calls, no reflection. At run time your code calls that class like any other.
MapStruct: the work happens before the program runsCustomerMapperinterface + annotationsjavac + processorbuild time onlyCustomerMapperImplplain Java, generatedyour codeimpl.toDto(c, clock)Dozer: the work happens on every runyour codemapper.map(src, Dst.class)reflection, first callscan classes, build a class mapreflection, every calllook up and invoke each accessorDst objectTop row: a mistake (a missing property) stops the build, and the runtime code is ordinary method calls.Bottom row: the same mistake is found, if at all, when that line first runs.
The picture’s argument is the position of the arrow labelled “build time only”. In the top row everything clever has already happened by the time the application starts, so a mistake — a target property nobody mapped, a typo in a property name — is a compile error. In the bottom row, which is how Dozer works, the mapper inspects classes through reflection, so the same mistake surfaces when that line first runs, or never. Here is the smallest mapper in the project: an entity-style class with a rename and a default, mapped to a record. The source is AddressMapper.java.
@Mapper(config = CentralConfig.class)
public interface AddressMapper {

    @Mapping(target = "zip", source = "postalCode")
    @Mapping(target = "country", source = "country", defaultValue = "IN")
    AddressDto toDto(Address address);

    /** The rename is declared once, above; this method inherits it in reverse. */
    @InheritInverseConfiguration(name = "toDto")
    Address toEntity(AddressDto dto);

    /** Field-by-field patch of an existing Address: a null component leaves the existing value alone. */
    @Mapping(target = "postalCode", source = "zip")
    @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    void update(AddressDto dto, @MappingTarget Address address);
You never write the implementation, but you can read it. This is what MapStruct generated, copied from 02-generated-implementation.txt:
public class AddressMapperImpl implements AddressMapper {

    @Override
    public AddressDto toDto(Address address) {
        if ( address == null ) {
            return null;
        }

        String zip = null;
        String country = null;
        String street = null;
        String city = null;

        zip = address.getPostalCode();
        if ( address.getCountry() != null ) {
            country = address.getCountry();
        }
        else {
            country = "IN";
        }
        street = address.getStreet();
        city = address.getCity();

        AddressDto addressDto = new AddressDto( street, city, zip, country );
Two things to notice. First, the rename (postalCode becomes zip) and the default (country falls back to "IN") are just an assignment and an if. Second, the record is built by one constructor call at the end, after every value is held in a local variable. A record has no setters, so there is nothing else MapStruct could do — and that single fact explains most of the record-specific surprises later in this article.
The generated class is yours to read. When a mapping does something unexpected, open target/generated-sources/annotations/ before you open the documentation. Every claim in this article about what MapStruct does was checked there, and the tests print the relevant methods into the transcripts so you can see them without building anything.
Going deeper: what the processor actually is

Opening the processor jar shows one service entry, org.mapstruct.ap.MappingProcessor, and a fixed list of -A options it accepts (for example mapstruct.defaultComponentModel, mapstruct.unmappedTargetPolicy, mapstruct.verbose). Both come straight out of the jar with javap; see 25-processor-jar-facts.txt.

Because the generator is only a compile-time tool, the runtime dependency is the small mapstruct jar that holds the annotations. The processor jar never needs to be on the application’s classpath.

Going deeper

The setup has one trap: the processor does not go in <dependencies>

The Maven wiring is two jars with two different jobs. mapstruct is an ordinary dependency (annotations only). mapstruct-processor goes in the compiler plugin’s annotationProcessorPaths. This is the relevant part of pom.xml:
<annotationProcessorPaths>
  <path>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>${mapstruct.version}</version>
  </path>
</annotationProcessorPaths>
Why the care? On recent JDKs javac no longer runs annotation processors it merely finds on the classpath (I ran only 25.0.3, and recall this changing in JDK 23); you must name them (-processorpath, which Maven’s annotationProcessorPaths produces) or switch processing on with -proc:full. The companion project compiled the same source twice with JDK 25.0.3 — processor on the plain classpath, then with -proc:full. The first compile succeeded, generated nothing, and the program failed only when it ran:
$ ls target/broken/processor-not-run
UsesMapper$Dst.class
UsesMapper$Src.class
UsesMapper.class

Exception in thread "main" java.lang.RuntimeException: java.lang.ClassNotFoundException: Cannot find implementation for UsesMapper
	at org.mapstruct.factory.Mappers.getMapper(Mappers.java:61)
	at UsesMapper.main(UsesMapper.java:15)
Caused by: java.lang.ClassNotFoundException: Cannot find implementation for UsesMapper
That is from 23-processor-on-the-plain-classpath.txt, which also shows the -proc:full run producing UsesMapperImpl. A build that leaves the processor on the plain classpath can therefore compile cleanly on JDK 25 and then fail at startup with Cannot find implementation for ....
The symptom to recognise. No compile error, but at run time ClassNotFoundException: Cannot find implementation for YourMapper (or, with Spring, NoSuchBeanDefinitionException for the mapper). The generated YourMapperImpl was never written. Check the processor path before you check anything else.
Going deeper: Lombok, and the order of processors

This module deliberately has no Lombok: records give you the constructor, accessors, equals and toString that Lombok was usually there for. If your entities still use Lombok, the question is whether MapStruct sees the getters and setters Lombok adds. The companion script ran four javac invocations with Lombok 1.18.48 on JDK 25 and a record target. With MapStruct first and no binding jar, it failed with Unmapped target properties: "name, age". With Lombok first, or with lombok-mapstruct-binding 0.2.0 on the path, it compiled and generated the mapper. Transcript: 24-lombok-processor-order.txt.

Take that as what happened with plain javac, not as a statement about Maven: Maven’s annotationProcessorPaths order and the binding jar were not exercised here. The rule that survives is to put Lombok before MapStruct and add lombok-mapstruct-binding if you use both.

IDEs: I did not run IntelliJ IDEA or Eclipse for this article, so I cannot report IDE-specific behaviour. If your IDE shows the mapper as unimplemented, check that annotation processing is enabled and points at the same processor path as Maven.

Going deeper

Nested objects, lists, sets and maps need no code, only the rare override

The real mapper is bigger. A Customer entity has a nested address, a list of orders (each with a list of lines), a set of roles and a map of attributes. The record CustomerDto has the same, plus a few computed fields. This is CustomerMapper.java (entity to record):
@Mapping(target = "fullName", expression = "java(customer.getFirstName() + \" \" + customer.getLastName())")
@Mapping(target = "age", source = "birthDate", qualifiedByName = "ageInYears")
@Mapping(target = "nickname", source = "nickname", defaultValue = "anonymous")
@Mapping(target = "origin", constant = "api")
CustomerDto toDto(Customer customer, @Context Clock clock);

@Named("ageInYears")
default int ageInYears(LocalDate birthDate, @Context Clock clock) {
Mapping the seeded sample customer produced this, copied from 03-entity-to-record.txt:
fullName   : Asha Mehta    <- expression = java(first + " " + last)
age        : 36    <- qualifiedByName, with the clock passed as @Context
nickname   : anonymous    <- source was null, defaultValue = "anonymous"
origin     : api    <- constant = "api"
address    : AddressDto[street=12 Park Road, city=Pune, zip=411001, country=IN]    <- postalCode renamed to zip; null country got defaultValue IN
roles      : [USER]   same instance as the entity's? false
attributes : {newsletter=yes}   same instance as the entity's? false

--- List<Order> -> List<OrderDto>, with a computed total ---
order id=100 status=PAID total=32.50
  OrderLineDto[sku=BOOK-1, quantity=2, unitPrice=12.50, lineTotal=25.00]
  OrderLineDto[sku=PEN-9, quantity=10, unitPrice=0.75, lineTotal=7.50]
Everything you asked for happened, and so did things you did not ask for. The nested Address became an AddressDto because uses = {AddressMapper.class, ...} told MapStruct a method for that pair exists. The List<Order> became a List<OrderDto> with a loop MapStruct wrote itself. The roles and attributes are copies: the transcript prints same instance as the entity's? false for both. And passwordHash is absent from the record, so no mapper can leak it — the safest field-hiding there is.
Going deeper: the @Mapping options used above, in one table
OptionWhere it appearsWhat it does
source / targetzip from postalCoderenames a property; with the same name you write nothing
expressionfullNamea Java expression pasted into the generated method; it can name the source parameter (customer)
constantorigin = "api"a fixed value, no source property involved
defaultValuenickname, countryused only when the source value is null
qualifiedByNameage, totalpicks one specific @Named method instead of the implicit one
ignore = trueid, passwordHash on the way insays “I know this target property exists and I am leaving it alone”

The expression for lineTotal is pasted verbatim into the generated OrderMapperImpl, so a typo inside the string is a compile error in generated code rather than a runtime error. The order total uses qualifiedByName = "sumOfLines"; the helper is annotated @Named, so no property picks it up by accident. Both are in OrderMapper.java.

Going deeper
Going deeper: the way back, and what @InheritInverseConfiguration does not invert

Records work as sources too: the generated code reads request.firstName(), not a getter (transcript 01 prints those lines). For the address, the reverse method says @InheritInverseConfiguration(name = "toDto") and receives the rename for free.

What it does not carry over is the defaultValue. After mapping a request with no country, the entity’s country is null, while mapping an entity with no country to a DTO yields IN:

address.postalCode        : 600001   <- @InheritInverseConfiguration turned zip back into postalCode
address.country           : null   <- defaultValue is NOT inverted; toDto gives IN, toEntity does not

A round trip entity → record → entity is therefore not the identity: the transcript shows the passwordHash lost (the record never had it) and a country that was null coming back as IN. Source: 04-record-to-entity.txt.

Going deeper

In a MapStruct Spring Boot app, a mapper is just another constructor-injected bean

One annotation, @Mapper(componentModel = "spring"), makes the generated class a @Component. Repeating that on every mapper invites drift, so the project keeps its three shared decisions in one CentralConfig.java and each mapper says @Mapper(config = CentralConfig.class):
@MapperConfig(
        componentModel = MappingConstants.ComponentModel.SPRING,
        injectionStrategy = InjectionStrategy.CONSTRUCTOR,
        unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CentralConfig {
InjectionStrategy.CONSTRUCTOR is what makes uses = {...} readable in Spring terms: the generated CustomerMapperImpl gets a constructor with one parameter per used mapper. From 02-generated-implementation.txt:
@Component
public class AddressMapperImpl implements AddressMapper {

    @Override
    public AddressDto toDto(Address address) {
        if ( address == null ) {
            return null;
        }

        String zip = null;
        String country = null;
        String street = null;
        String city = null;

        zip = address.getPostalCode();
        if ( address.getCountry() != null ) {
            country = address.getCountry();
        }
        else {
            country = "IN";
        }
        street = address.getStreet();
        city = address.getCity();

        AddressDto addressDto = new AddressDto( street, city, zip, country );

        return addressDto;
    }

    @Override
    public Address toEntity(AddressDto dto) {
        if ( dto == null ) {
            return null;
        }

        Address address = new Address();

        address.setPostalCode( dto.zip() );
        address.setCountry( dto.country() );
        address.setStreet( dto.street() );
        address.setCity( dto.city() );

        return address;
    }

    @Override
    public void update(AddressDto dto, Address address) {
        if ( dto == null ) {
            return;
        }

        if ( dto.zip() != null ) {
            address.setPostalCode( dto.zip() );
        }
        if ( dto.street() != null ) {
            address.setStreet( dto.street() );
        }
        if ( dto.city() != null ) {
            address.setCity( dto.city() );
        }
        if ( dto.country() != null ) {
            address.setCountry( dto.country() );
        }
    }
}

--- CustomerMapperImpl: constructor injection, then toDto ---
@Component
public class CustomerMapperImpl implements CustomerMapper {

    private final AddressMapper addressMapper;
    private final OrderMapper orderMapper;

    @Autowired
    public CustomerMapperImpl(AddressMapper addressMapper, OrderMapper orderMapper) {

        this.addressMapper = addressMapper;
        this.orderMapper = orderMapper;
CustomerControllerweb layerCustomerMapperImpl@ComponentAddressMapperImpl@ComponentOrderMapperImpl@ComponentClockcontext arguses = {AddressMapper.class, OrderMapper.class} becomes a constructor with two parameters; Spring supplies them.The Clock is not a bean of the mapper: the caller hands it to each call as a @Context argument (dashed).
The diagram shows the dependencies Spring sees: the controller needs one bean, and that bean needs two more. Nothing is field-injected. The dashed arrow is the part that is easy to misread — the Clock is not injected into the mapper. The age calculation needs “today”, and a mapper that calls LocalDate.now() is untestable, so the caller passes a Clock as a @Context parameter. MapStruct forwards it to every helper that declares it; the tests pass a fixed clock and the age is 36 on every run.
unmappedTargetPolicy = ERROR is the setting that earns this library its keep. MapStruct’s default is only to warn about target properties it cannot fill. With ERROR, adding a component to a record without deciding where it comes from stops the build. Section “What the compiler says” below shows the message.
Going deeper: records over HTTP in Spring Boot 4 (Jackson 3, MockMvc)

Boot 4 uses Jackson 3, whose databind classes live under tools.jackson instead of com.fasterxml.jackson. The web test prints the bean it injects: tools.jackson.databind.json.JsonMapper. Records need no annotations and no extra module to be read or written; the controller takes a CustomerRequest record and returns a CustomerDto record. The test slice moved packages too: @WebMvcTest is now org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest, from spring-boot-starter-webmvc-test.

Real responses from 15-spring-boot-4-records-over-http.txt (the second one has no nickname, tier or country in the request body):

--- POST /customers  (no nickname, no tier, no country) ---
{"id":2,"fullName":"Ravi Iyer","email":"[email protected]","birthDate":"1985-01-02","age":41,"nickname":"anonymous","address":{"street":"1 Lake View","city":"Chennai","zip":"600001","country":"IN"},"roles":["ADMIN"],"attributes":{},"orders":null,"origin":"api"}

Notice "orders":null: a customer created through the mapper has null orders because the entity’s list was never set, and nothing turned that into an empty list. The next section is about that exact decision. For Jackson 3 itself, see this blog’s Jackson 3 migration guide and Jackson with records and Optionals.

Going deeper

A null in the source can erase data, and you choose when

Mapping is mostly about what to do with null. MapStruct has separate switches for four different situations, and mixing them up is a common source of bugs.
SituationSwitchDefault
a property is null on the sourcenullValueCheckStrategyassign it, unless a conversion needs a check
a property is null when updating an existing objectnullValuePropertyMappingStrategySET_TO_NULL
the whole source object is nullnullValueMappingStrategyRETURN_NULL
a source list or map is nullnullValueIterableMappingStrategy / nullValueMapMappingStrategyRETURN_NULL
The first one matters when the target has its own default. Customer declares tier = "STANDARD" in a field initialiser. Mapping a request with a null tier overwrites it, unless the method says @BeanMapping(nullValueCheckStrategy = ALWAYS). From 04-record-to-entity.txt:
tier                      : null   <- the entity said STANDARD; the request said null
tier with nullValueCheckStrategy = ALWAYS : STANDARD
The second matters for PATCH-style updates. The same all-null patch is applied twice: once through a method with nullValuePropertyMappingStrategy = IGNORE, once through one without. From 05-null-strategies.txt:
--- patch(): nullValuePropertyMappingStrategy = IGNORE, applied with a patch whose components are all null ---
before: [email protected], nickname=ash, roles=[USER], address=12 Park Road/Pune/411001
after : [email protected], nickname=ash, roles=[USER], address=12 Park Road/Pune/411001

--- replace(): the default strategy (SET_TO_NULL), same all-null patch ---
before: [email protected], nickname=ash, roles=[USER], address=12 Park Road/Pune/411001
after : email=null, nickname=null, roles=null, address=null
The default wiped the e-mail, nickname, roles and address. For whole objects and collections, a second mapper (LenientOrderMapper.java) turns on RETURN_DEFAULT:
strict.toDto(null)          : null
lenient.toDto(null)         : OrderDto[id=null, status=null, placedAt=null, lines=null, total=null]
strict  order, lines = null : lines=null
lenient order, lines = null : lines=[]
strict.toDtos(null)         : null
One of those lines is the surprise: lenient.toDto(null) returns an object, but its lines is still null, not []. The list default only applies when the list-mapping method is actually called, and for a null parent it never is.
Going deeper: why IGNORE is not the same as ALWAYS

IGNORE produces the null checks: the generated patch method, printed at the end of 05-null-strategies.txt, reads if ( patch.email() != null ) before each assignment. The difference is scope: nullValueCheckStrategy is about creating an object (keep the target’s initial value), nullValuePropertyMappingStrategy is about updating one (keep the existing value).

    public void patch(CustomerPatch patch, Customer customer) {
        if ( patch == null ) {
            return;
        }

        if ( patch.email() != null ) {
            customer.setEmail( patch.email() );
        }
        if ( patch.nickname() != null ) {
            customer.setNickname( patch.nickname() );
        }
        if ( patch.address() != null ) {
            if ( customer.getAddress() == null ) {
                customer.setAddress( new Address() );
            }
            addressMapper.update( patch.address(), customer.getAddress() );
        }
        if ( customer.getRoles() != null ) {
            Set<String> set = patch.roles();
            if ( set != null ) {
                customer.getRoles().clear();
                customer.getRoles().addAll( set );
            }
        }
        else {
            Set<String> set = patch.roles();
            if ( set != null ) {
                customer.setRoles( new LinkedHashSet<String>( set ) );
            }
        }
    }

The collection switches also exist globally as processor options (-Amapstruct.nullValueIterableMappingStrategy and -Amapstruct.nullValueMapMappingStrategy are in the list read from the jar), so a team can set a policy once. This module only sets them per mapper.

Going deeper

You cannot update a record in place, and the compiler will not tell you

Updating an existing object is the second job of a mapper: void patch(CustomerPatch patch, @MappingTarget Customer customer). Two facts about it matter before records even come into it. From 06-update-in-place.txt:
same Address instance as before? true   <- AddressMapper.update was called on it
same roles Set instance as before? true  <- cleared and refilled, not replaced
another holder of the old roles Set now sees: [AUDITOR]

--- an immutable collection on the target ---
patching roles on a Set.of(...) target threw: java.lang.UnsupportedOperationException
Nested objects and collections are changed in place: the same Address instance, and the same Set cleared and refilled. Anybody else holding that set sees the change, and if the set is immutable (Set.of) the patch throws UnsupportedOperationException. That is the generated customer.getRoles().clear(), not a MapStruct bug, but it is worth knowing before you put a mapper in front of Hibernate collections. Now the record case. I expected a compile error for a @MappingTarget whose type is a record. There is none. With unmappedTargetPolicy = ERROR the project compiles and MapStruct generates an empty method. From 20-update-method-on-a-record-compiles-to-a-no-op.txt:
public class BrokenMapperImpl implements BrokenMapper {

    @Override
    public void update(BrokenMapper.Patch patch, BrokenMapper.Dst target) {
        if ( patch == null ) {
            return;
        }
    }
}
The method body is: if the patch is null, return. Nothing else. A record has no write accessors, so MapStruct finds zero target properties to map; zero unmapped properties is not an error, so even unmappedTargetPolicy = ERROR stays silent. The mapper compiles, the endpoint would return 200, and nothing is updated. The source is under src/broken/update-record/ (outside the Maven build, so it can compile for the transcript without breaking the project).
The fingerprint of this bug: a void update(X x, @MappingTarget SomeRecord target) method, a generated body that is just a null check, and a test that finds the record unchanged. If a method must “change” a record, it has to return a new one.
That is what the download below does: it takes the current record and the patch as two source parameters and builds a new record. It is not in the module because it overlaps with patch; you can download mapstruct-RecordPatchMapper.java. I compiled it against the module’s classes and ran it: a patch carrying only a nickname returned the old record with the new nickname.
@Mapper(componentModel = "spring")
interface RecordPatchMapper {

    @Mapping(target = ".", source = "current")
    @Mapping(target = "email", expression = "java(patch.email() != null ? patch.email() : current.email())")
    @Mapping(target = "nickname", expression = "java(patch.nickname() != null ? patch.nickname() : current.nickname())")
    @Mapping(target = "address", expression = "java(patch.address() != null ? patch.address() : current.address())")
    @Mapping(target = "roles", expression = "java(patch.roles() != null ? patch.roles() : current.roles())")
    CustomerDto apply(CustomerDto current, CustomerPatch patch);
}
One more thing the compiler told me while I wrote it, which is not in a transcript: with two source records that both have address and roles, MapStruct refuses with Several possible source properties for target property "address". Every shared component therefore needs an explicit line, which is why the download has four @Mapping lines beyond the . copy.
Going deeper: when records and entities disagree about mutability

An entity that Hibernate manages is mutable; the record you return from the API is not. The three update styles are therefore: patch the entity in place (@MappingTarget on the entity, as CustomerMapper.patch does); build a new record from the old one (the download); or skip mapping for the update and call a domain method on the entity. The first is the least code and the one with the in-place collection behaviour shown above.

I did not run Hibernate for this article, so I make no claim about how in-place collection updates interact with a persistence context.

Going deeper

@AfterMapping and @BeforeMapping change meaning when the target is a record

Hooks are methods MapStruct calls around a mapping. The module uses one for real: stamp(@MappingTarget Customer, @Context Clock) lower-cases the e-mail and sets createdAt on every mapping that produces a Customer (04-record-to-entity.txt shows the e-mail trimmed and lower-cased). On a mutable entity that is straightforward. On a record target the model changes, so a probe mapper (HookOrderMapper.java) logged the call order for a record and for a bean, from 07-hooks-on-records.txt:
--- target is a record: toRecord(Pair) ---
@BeforeMapping(source)
@BeforeMapping(source, @MappingTarget Boxed) target=Boxed[name=pen, qty=3]
@AfterMapping(source, @MappingTarget Boxed) target=Boxed[name=pen, qty=3]
@AfterMapping(source)
--- target is a mutable bean: toBean(Pair) ---
@BeforeMapping(source)
@BeforeMapping(source, @MappingTarget BoxedBean) name=null
  BoxedBean.setName(pen)
  BoxedBean.setQty(3)
Record target: Boxed(name, qty)1 @BeforeMapping(source)2 read name, qty into locals3 new Boxed(name, qty)4 @BeforeMapping(source, target)5 @AfterMapping(source, target)6 @AfterMapping(source)Bean target: BoxedBean with setters1 @BeforeMapping(source)2 new BoxedBean()3 @BeforeMapping(source, target)4 setName(..), setQty(..)5 @AfterMapping(source, target)6 @AfterMapping(source)Amber is the hook whose name says “before”. On a bean it runs on an empty object, before any setter.On a record it runs after the constructor, with the finished, unchangeable record.
The picture compares the two sequences. The amber step is the hook whose name promises “before”. For a bean it really is before: it sees name=null and then the setters fill the object. For a record there is nothing that could run before the constructor and still touch the target, so MapStruct calls the hook right after new Boxed(...), with the finished record (target=Boxed[name=pen, qty=3] in the transcript). The source-only hooks, @BeforeMapping(source) and @AfterMapping(source), behave the same for both. So what can you do to a record after it is built? Nothing — it is immutable. Two honest options exist, and both are in the module. First, give the record a builder: MapStruct uses a public static builder() method if there is one, and the hook can then take the builder as its @MappingTarget. ReceiptMapper.java does that, with the builder declared in ReceiptDto.java. The result and the generated method, from 09-builder-and-decorator.txt:
ReceiptDto[orderId=100, total=32.50, summary=order 100: 2 line(s)]
        ReceiptDto.Builder receiptDto = ReceiptDto.builder();

        receiptDto.orderId( order.getId() );
        receiptDto.total( orderMapper.sumOfLines( order.getLines() ) );

        summarise( order, receiptDto );

        return receiptDto.build();
Second, wrap the generated mapper in a decorator that builds a new record from the generated one. InvoiceMapperDecorator.java does that with @DecoratedWith on InvoiceMapper.java. With componentModel = "spring" two beans implement the interface, and the decorated one is @Primary:
bean invoiceMapperImpl    -> InvoiceMapperImpl
bean invoiceMapperImpl_   -> InvoiceMapperImpl_
injected InvoiceMapper (the @Primary one) : InvoiceDto[id=100, status=PAID, total=32.50, display=#100 [PAID] 32.50]
the generated delegate on its own          : InvoiceDto[id=100, status=PAID, total=32.50, display=null]
Decorators cannot use constructor injection in MapStruct 1.6.3. The decorator takes its delegate through a @Qualifier("delegate") setter. I first wrote a constructor, and the generated subclass failed to compile because MapStruct emits it without one, from 22-error-decorator-constructor.txt:

target/broken/decorator-constructor/BrokenMapperImpl.java:13: error: constructor BrokenMapperDecorator in class BrokenMapperDecorator cannot be applied to given types;
public class BrokenMapperImpl extends BrokenMapperDecorator {
       ^
  required: BrokenMapper
  found:    no arguments
  reason: actual and formal argument lists differ in length
Which to pick? A builder keeps the logic next to the mapping and costs a hand-written builder per record. A decorator works for any record but rebuilds it by hand, so a new component has to be added in two places.
An @ObjectFactory that returns a record throws every field away, silently. It is natural to reach for a factory to control construction, and for a bean it works. For a record the generated method is just Boxed boxed = create( pair ); return boxed;: the factory’s object is the result, and no component is ever copied. With unmappedTargetPolicy = ERROR there was no error either. From 08-object-factory-trap.txt:

source : Pair[name=pen, qty=3]
result : Boxed[name=from-the-factory, qty=0]
Going deeper: @Context, and why a cycle cannot be a record

@Context passes an extra argument through the whole generated call tree without it being a source. The module uses it for the Clock. The other classic use is cycle tracking: a context object remembers which source objects were already mapped, so a graph with a loop maps to a graph with a loop. With a bean target that works.

A record target cannot do it, for a structural reason visible in the generated code: the method recurses into manager before it calls the constructor, so the “remember this target” call can only happen afterwards. Two employees who manage each other overflow the stack whichever context you pass. From 10-context-and-cycles.txt:

bean target + @Context CycleContext : ann.manager.manager is ann? true
record target + the same @Context   : java.lang.StackOverflowError

If your data has cycles, map them to a shape that does not: replace the back-reference with an id or a small reference record. Probe sources: CycleMapper.java and CycleContext.java.

Going deeper

What the compiler says when a mapping is wrong

Because the generator runs inside javac, its complaints look like ordinary compile errors, and they are the main reason to prefer this approach. The module keeps broken sources under src/broken/, outside the Maven build, and scripts/compile-errors.sh compiles each one and commits the compiler’s output unedited. The most common one, a target component nobody mapped (17-error-unmapped-target.txt):
src/broken/unmapped-target/BrokenMapper.java:12: error: Unmapped target property: "note".
    Dst map(Src src);
        ^
1 error
A typo in a source name gets a suggestion (19-error-missing-source-property.txt):
src/broken/missing-source/BrokenMapper.java:12: error: No property named "nmae" exists in source parameter(s). Did you mean "name"?
    @Mapping(source = "nmae", target = "title")
                      ^
1 error
Two applicable methods, and MapStruct will not guess (18-error-ambiguous-mapping-methods.txt). The first character of BrokenMapper is missing in the message below; that is how 1.6.3 printed it for nested types in the default package, and I left it as the compiler wrote it:
src/broken/ambiguous/BrokenMapper.java:13: error: Ambiguous mapping methods found for mapping property "rokenMapper.Money price" to String: String plain(rokenMapper.Money m), String formatted(rokenMapper.Money m). See https://mapstruct.org/faq/#ambiguous for more info.
    Dst map(Src src);
        ^
1 error
And a property type with no conversion at all (21-error-cannot-map-type.txt):
src/broken/cannot-map/BrokenMapper.java:17: error: Can't map property "rokenMapper.Tag tag" to "LocalDate tag". Consider to declare/implement a mapping method: "LocalDate map(rokenMapper.Tag value)".
    Dst map(Src src);
        ^
1 error
Going deeper: the fixes, and the one error that does not exist
Message starts withUsual fix
Unmapped target propertyadd a @Mapping with source, expression, constant or ignore = true; the point of the policy is that you must decide
No property named ... Did you meancorrect the name; records are matched by component name
Ambiguous mapping methodsgive one method a @Named and select it with qualifiedByName
Can't map propertywrite the missing conversion method; the message prints the signature to implement

The error that does not exist is the one for @MappingTarget on a record (previous section). The compiler’s silence there is the reason this project asserts behaviour in tests rather than relying on the compile step alone.

Going deeper

Test the mapping you wrote, not the code MapStruct generated

A generated mapper needs fewer tests than a hand-written one — the library already guarantees that a matching name is copied — but the lines you wrote (expressions, defaults, qualifiers, null strategies) are plain logic. The cheapest set-up is no Spring at all: construct the generated class yourself. The tempting shortcut, Mappers.getMapper, only works for a mapper without dependencies. From 11-testing-mappers-plain.txt:
AddressMapper (no dependencies)   : AddressMapperImpl
CustomerMapper (uses = {...})      : RuntimeException
  root cause                      : java.lang.NoSuchMethodException: com.ankurm.mapstruct.mapper.CustomerMapperImpl.<init>()
With componentModel = "spring" and constructor injection the generated CustomerMapperImpl has no no-argument constructor, which is what Mappers.getMapper looks for. Construct it directly instead — new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl()), as the tests’ Fixtures.java does:
--- 2. new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl()) ---
no Spring, no reflection        : Asha Mehta
When you do want Spring, there are two sizes. A context of only the generated classes (@SpringJUnitConfig, which is @ContextConfiguration plus the JUnit extension) versus the whole application. From 12-testing-mappers-contextconfiguration.txt and 13-testing-mappers-springboottest.txt:
bean definitions in the context : 11
bean definitions in the context : 153
11 bean definitions against 153, for the same mapping result. Use the narrow one for mapper tests and keep @SpringBootTest for tests that are about wiring.
Going deeper: a property-level strategy that fails when you add a component

The last test pattern guards against the quietest regression: someone adds a component to CustomerDto, maps it wrongly, and no test notices. The test changes one source property at a time, asserts that exactly the matching record component changes, and then asserts that the set of covered components equals the set of record components. A new component without a mutation entry fails the build. From 14-property-level-test-strategy.txt:

record components   : [address, age, attributes, birthDate, email, fullName, id, nickname, orders, roles]
covered by a test   : [address, age, attributes, birthDate, email, fullName, id, nickname, orders, roles]

The component origin is excluded because it is a constant with no source property to change; the test says so in a comment. Source: TestingStrategiesTests.java.

Going deeper

Generated code is about as fast as hand-written code; reflection is not

The older Dozer post maps by reflection. To compare fairly, the test maps the same Customer into a setter-based CustomerView three ways — by hand, with MapStruct, with Dozer 7.0.0 — because Dozer is a bean mapper and I did not try to point it at a record. Dozer 7.0.0 ran on JDK 25 without complaint, which I did not assume it would. The code is BenchmarkTests.java and one run’s results are in 16-indicative-timing-vs-handwritten-and-dozer.txt:
Java 25.0.3+9-LTS-195, one JVM, 200000 iterations per round, best of 5 rounds after a 50000-iteration warm-up
mapping: Customer -> CustomerView (1 address, 1 order with 2 lines, 1 set, 1 map)

hand-written             95 ns/op
MapStruct 1.6.3          92 ns/op   (1.0x hand-written)
Dozer 7.0.0           11498 ns/op   (122x hand-written)

first call, cold: MapStruct 0.13 ms, Dozer 80.9 ms
results equal: true

Indicative only. Timings vary between runs and machines; do not quote them as a benchmark result.
Read these numbers as indicative. It is a hand-rolled loop, not JMH: one JVM, one machine, a 50,000-iteration warm-up and the best of five rounds. They change on every run, which is why that test asserts only that the three results are equal. The shape is what to take away: MapStruct close to hand-written code, Dozer far behind, and a first Dozer call that costs far more than a MapStruct one while it builds its class map.
The mechanism is the one in the first diagram. The MapStruct column is a method that does setX(getX()) a few dozen times; the Dozer column discovers the properties by reflection, then calls each accessor reflectively on every invocation. On a hot request path that is the difference between noise and a line in the profiler. For a nightly batch of a thousand objects it is not worth a migration.
Going deeper: what the hand-written version looks like, and when Dozer is still fine

The fair opponent is not Dozer but the mapper you would write by hand. It is HandWritten.java: about sixty lines for one aggregate, including the null checks MapStruct generates for free. MapStruct’s version of the same mapping is a few lines of declaration (ViewMapper.java), and it stays correct when a field is added, because the unmapped-target policy complains.

Dozer is still reasonable for a legacy codebase that already depends on it and is not performance-sensitive; the older post covers its features. In this comparison it worked on mutable beans, and it leaves property mistakes to run time; both are costs on a codebase that has moved to records.

Going deeper

Surprises found while writing this

  • @MappingTarget on a record compiles to an empty method, even with unmappedTargetPolicy = ERROR. I expected an error. (transcript 20)
  • @BeforeMapping with a target runs after the constructor for records. (transcript 07)
  • @ObjectFactory returning a record discards every property with no warning. (transcript 08)
  • Constructor-injected decorators do not compile in 1.6.3 with the Spring component model. (transcript 22)
  • JDK 23+ builds that kept the processor on the classpath compile and then fail at run time. (transcript 23)
  • @InheritInverseConfiguration does not invert defaultValue, so entity → record → entity is not the identity. (transcript 04)
  • Dozer 7.0.0 works on JDK 25, which was not a given for a reflection library with old dependencies. (transcript 16)
  • Not verified here: IDE behaviour (IntelliJ, Eclipse), Maven-side Lombok ordering, the Gradle setup, and Hibernate collections. Everything else above was run.

Should you use a mapper library at all?

Sometimes not. With records, a mapping method that is ten lines long is easy to write, easy to read and has no build-time magic, and the hand-written row of the timing table shows you lose nothing at run time. A mapper library earns its place when you have many DTOs, nested graphs or collections, and a team that will otherwise forget a field. If you have three DTOs, write them by hand.

When you do use MapStruct, four rules cover most of this article: set unmappedTargetPolicy = ERROR in one shared config, put the processor in annotationProcessorPaths, never write an update method whose target is a record, and write the property-level test that fails when a component is added.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.