New posts for Spring and Hibernate

This commit is contained in:
2026-11-06 01:05:27 +05:30
parent 51913ad9d4
commit dc13293046
136 changed files with 5268 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
# mapstruct
Companion project for the article **[MapStruct with Spring Boot 4 and Java Records: DTO Mapping Done Right](https://ankurm.com/mapstruct-spring-boot-4-java-records-dto-mapping/)** on **[ankurm.com](https://ankurm.com)**.
Every console block quoted in the article came out of `output/`. Transcripts 01-16 are written by the test suite
(so a claim that stops being true turns the build red); 17-24 are the compiler's own words, captured by
`scripts/compile-errors.sh` from the deliberately broken sources in `src/broken/`; 25 is read out of the
processor jar by `scripts/capture-facts.sh`.
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible "going deeper"
sections inside the article itself.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.2 (newest GA in Maven Central's metadata when this was written; the sibling modules use 4.1.1) |
| Spring Framework | 7.0.10 |
| MapStruct | 1.6.3 (newest GA; 1.7.0.Beta2 exists and is not used) |
| Jackson | 3.1.7 (`tools.jackson`) |
| Dozer (test scope, timing only) | 7.0.0 |
| JDK | 25 |
| Maven | 3.9 |
No Lombok. Records make it unnecessary here, and `scripts/compile-errors.sh` shows what Lombok does to the
processor path when you do use it (transcript 24).
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn test # runs the scenarios and rewrites output/01-16
./scripts/run-all.sh # everything, including 17-25 (needs the jars from one `mvn test` in ~/.m2)
```
The compiler plugin gets the generator through `annotationProcessorPaths` (see `pom.xml`). The generated
sources land in `target/generated-sources/annotations/`, where the tests read them back to print them.
## Source layout
| Path | What it holds |
|---|---|
| `domain/` | mutable entity-style classes: `Customer`, `Address`, `Order`, `OrderLine` |
| `dto/` | records: `CustomerDto`, `CustomerRequest`, `CustomerPatch`, `AddressDto`, `OrderDto`, `ReceiptDto` (with a builder), ... |
| `mapper/` | `CentralConfig` (Spring beans, constructor injection, unmapped target = error) and the mappers |
| `web/` | a small controller and an in-memory repository, so the records can be seen over HTTP |
| `src/test/.../probe/` | throwaway mappers that answer "what happens if...": hook order, `@ObjectFactory`, cycles, timing |
| `src/broken/` | sources that must not compile; never part of the Maven build |
## Captured output
| File | What it shows |
|---|---|
| `01-records-as-source-and-target.txt` | a record target has no setters; the generated code calls the constructor |
| `02-generated-implementation.txt` | the generated `AddressMapperImpl` and `CustomerMapperImpl.toDto`, read from `target/` |
| `03-entity-to-record.txt` | nested objects, List/Set/Map, expression, constant, defaultValue, qualifiedByName, `@Context` |
| `04-record-to-entity.txt` | a record as source, `@AfterMapping`, `@InheritInverseConfiguration`, `nullValueCheckStrategy` |
| `05-null-strategies.txt` | `IGNORE` versus the default on a patch; `RETURN_DEFAULT` for whole objects and collections |
| `06-update-in-place.txt` | `@MappingTarget` updates mutate nested objects and collections, including immutable ones |
| `07-hooks-on-records.txt` | `@BeforeMapping` / `@AfterMapping` call order for a record target and a bean target |
| `08-object-factory-trap.txt` | an `@ObjectFactory` that returns a record: nothing is copied, no error |
| `09-builder-and-decorator.txt` | post-processing a record through its builder, and through `@DecoratedWith` |
| `10-context-and-cycles.txt` | `@Context` cycle tracking works for beans and cannot work for records |
| `11-testing-mappers-plain.txt` | `Mappers.getMapper` versus constructing the generated class |
| `12-testing-mappers-contextconfiguration.txt` | a Spring context of just the generated classes |
| `13-testing-mappers-springboottest.txt` | the same mapper inside `@SpringBootTest` |
| `14-property-level-test-strategy.txt` | one mutation per record component, and a completeness check |
| `15-spring-boot-4-records-over-http.txt` | MockMvc, Jackson 3, records in and out |
| `16-indicative-timing-vs-handwritten-and-dozer.txt` | **indicative** timings; differs on every run |
| `17-error-unmapped-target.txt` | javac: `Unmapped target property` |
| `18-error-ambiguous-mapping-methods.txt` | javac: `Ambiguous mapping methods found` |
| `19-error-missing-source-property.txt` | javac: `No property named ... Did you mean ...?` |
| `20-update-method-on-a-record-compiles-to-a-no-op.txt` | `@MappingTarget` on a record compiles, and generates an empty method |
| `21-error-cannot-map-type.txt` | javac: `Can't map property` |
| `22-error-decorator-constructor.txt` | javac: the generated decorator subclass has no constructor to call |
| `23-processor-on-the-plain-classpath.txt` | JDK 25 compiles without running the processor if it is only on `-cp` |
| `24-lombok-processor-order.txt` | Lombok before/after MapStruct, with and without `lombok-mapstruct-binding` |
| `25-processor-jar-facts.txt` | the processor service entry and its `-A` options, read with `javap` |
@@ -0,0 +1,14 @@
# A record target has no setters: MapStruct calls the canonical constructor
CustomerDto is a record : true
setters on CustomerDto : 0
setters on Customer (entity) : 14
--- the line the generated toDto uses to build the record ---
CustomerDto customerDto = new CustomerDto( id, fullName, email, birthDate, age, nickname, address, roles, attributes, orders, origin );
--- record as SOURCE: the generated toEntity reads accessors, not getters ---
customer.setFirstName( request.firstName() );
customer.setLastName( request.lastName() );
customer.setEmail( request.email() );
customer.setBirthDate( request.birthDate() );
@@ -0,0 +1,127 @@
# What MapStruct 1.6.3 generated, read from target/generated-sources (header and imports trimmed)
--- AddressMapperImpl ---
@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;
}
public CustomerDto toDto(Customer customer, Clock clock) {
if ( customer == null ) {
return null;
}
int age = 0;
String nickname = null;
Long id = null;
String email = null;
LocalDate birthDate = null;
AddressDto address = null;
Set<String> roles = null;
Map<String, String> attributes = null;
List<OrderDto> orders = null;
age = ageInYears( customer.getBirthDate(), clock );
if ( customer.getNickname() != null ) {
nickname = customer.getNickname();
}
else {
nickname = "anonymous";
}
id = customer.getId();
email = customer.getEmail();
birthDate = customer.getBirthDate();
address = addressMapper.toDto( customer.getAddress() );
Set<String> set = customer.getRoles();
if ( set != null ) {
roles = new LinkedHashSet<String>( set );
}
Map<String, String> map = customer.getAttributes();
if ( map != null ) {
attributes = new LinkedHashMap<String, String>( map );
}
orders = orderMapper.toDtos( customer.getOrders() );
String fullName = customer.getFirstName() + " " + customer.getLastName();
String origin = "api";
CustomerDto customerDto = new CustomerDto( id, fullName, email, birthDate, age, nickname, address, roles, attributes, orders, origin );
return customerDto;
}
+17
View File
@@ -0,0 +1,17 @@
# Customer (mutable entity) -> CustomerDto (record), with nested Address, a List of Orders, a Set and a Map
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]
--- what is NOT in the record ---
passwordHash is a component of CustomerDto? false
+13
View File
@@ -0,0 +1,13 @@
# CustomerRequest (record) -> Customer (entity); AddressMapper.toEntity inherits the rename in reverse
email after @AfterMapping : '[email protected]' (request carried ' [email protected] ')
createdAt (from @Context) : 2026-11-05T00:00:00Z
address.postalCode : 600001 <- @InheritInverseConfiguration turned zip back into postalCode
address.country : null <- defaultValue is NOT inverted; toDto gives IN, toEntity does not
tier : null <- the entity said STANDARD; the request said null
tier with nullValueCheckStrategy = ALWAYS : STANDARD
id / passwordHash / orders : null / null / null <- ignore = true
--- round trip: entity -> record -> entity ---
passwordHash survives the round trip? false
address country survives? null -> IN
+50
View File
@@ -0,0 +1,50 @@
# Null handling: IGNORE vs the default on a patch, and RETURN_DEFAULT on whole objects and collections
--- 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
--- whole objects and collections: OrderMapper (default) vs LenientOrderMapper (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
--- the generated patch() (IGNORE): every assignment is guarded ---
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 ) );
}
}
}
+11
View File
@@ -0,0 +1,11 @@
# @MappingTarget updates: nested objects and collections are changed in place
--- patch only the city and the roles ---
address: [email protected], nickname=null, roles=[USER, ADMIN], address=12 Park Road/Mumbai/411001
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
+40
View File
@@ -0,0 +1,40 @@
# @BeforeMapping / @AfterMapping on a record target versus a bean target (call order, recorded)
--- 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)
@AfterMapping(source, @MappingTarget BoxedBean) name=pen
@AfterMapping(source)
--- the generated toRecord, which is why ---
public Probes.Boxed toRecord(Probes.Pair pair) {
beforeSource( pair );
if ( pair == null ) {
return null;
}
String name = null;
int qty = 0;
name = pair.name();
qty = pair.qty();
Probes.Boxed boxed = new Probes.Boxed( name, qty );
beforeRecord( pair, boxed );
afterRecord( pair, boxed );
afterSource( pair );
return boxed;
}
@@ -0,0 +1,16 @@
# @ObjectFactory returning a record: the factory's object is the result, and nothing is copied into it
source : Pair[name=pen, qty=3]
result : Boxed[name=from-the-factory, qty=0]
compiled with unmappedTargetPolicy = ERROR, and no error was reported
--- the generated method ---
public Probes.Boxed toRecord(Probes.Pair pair) {
if ( pair == null ) {
return null;
}
Probes.Boxed boxed = create( pair );
return boxed;
}
@@ -0,0 +1,31 @@
# Post-processing a record: @AfterMapping on its builder, or a @DecoratedWith decorator
--- ReceiptMapper: the hook receives ReceiptDto.Builder ---
ReceiptDto[orderId=100, total=32.50, summary=order 100: 2 line(s)]
public ReceiptDto toReceipt(Order order) {
if ( order == null ) {
return null;
}
ReceiptDto.Builder receiptDto = ReceiptDto.builder();
receiptDto.orderId( order.getId() );
receiptDto.total( orderMapper.sumOfLines( order.getLines() ) );
summarise( order, receiptDto );
return receiptDto.build();
}
--- InvoiceMapper: @DecoratedWith, so two beans implement the interface ---
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]
--- the generated class that decorates ---
@Component
@Primary
public class InvoiceMapperImpl extends InvoiceMapperDecorator {
}
@@ -0,0 +1,28 @@
# @Context cycle tracking: it works for a bean target and cannot work for a record target
bean target + @Context CycleContext : ann.manager.manager is ann? true
record target + the same @Context : java.lang.StackOverflowError
--- why: the generated record mapping recurses BEFORE it can call the constructor ---
public Probes.EmployeeRecord toRecord(Probes.Employee employee, CycleContext context) {
Probes.EmployeeRecord target = context.lookup( employee, Probes.EmployeeRecord.class );
if ( target != null ) {
return target;
}
if ( employee == null ) {
return null;
}
String name = null;
Probes.EmployeeRecord manager = null;
name = employee.name;
manager = toRecord( employee.manager, context );
Probes.EmployeeRecord employeeRecord = new Probes.EmployeeRecord( name, manager );
context.remember( employee, employeeRecord );
return employeeRecord;
}
@@ -0,0 +1,10 @@
# Testing a generated mapper without Spring: Mappers.getMapper versus constructing the Impl
--- 1. Mappers.getMapper(...) ---
AddressMapper (no dependencies) : AddressMapperImpl
CustomerMapper (uses = {...}) : RuntimeException
root cause : java.lang.NoSuchMethodException: com.ankurm.mapstruct.mapper.CustomerMapperImpl.<init>()
--- 2. new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl()) ---
no Spring, no reflection : Asha Mehta
@@ -0,0 +1,6 @@
# @SpringJUnitConfig(classes = {the generated impls}): only the beans the mapper needs
bean definitions in the context : 11
bean names : org.springframework.context.annotation.internalConfigurationAnnotationProcessor, org.springframework.context.annotation.internalAutowiredAnnotationProcessor, org.springframework.context.annotation.internalCommonAnnotationProcessor, org.springframework.context.event.internalEventListenerProcessor, org.springframework.context.event.internalEventListenerFactory, customerMapperImpl, addressMapperImpl, orderMapperImpl, contextConfigurationMapperTest.PinnedClock, org.springframework.test.context.support.internalDynamicPropertyRegistrarBeanInitializer, clock
mapper injected as : CustomerMapperImpl
mapped : Asha Mehta
@@ -0,0 +1,6 @@
# @SpringBootTest: the same mapper, inside the whole application
bean definitions in the context : 153
includes the web controller : true
clock is the application's own : Z
mapped : Asha Mehta
@@ -0,0 +1,15 @@
# Property-level mapper test: change one entity property, expect exactly the right DTO component to change
id changes when its source changes : true
fullName changes when its source changes : true
email changes when its source changes : true
birthDate changes when its source changes : true
age changes when its source changes : true
nickname changes when its source changes : true
address changes when its source changes : true
roles changes when its source changes : true
attributes changes when its source changes : true
orders changes when its source changes : true
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]
@@ -0,0 +1,15 @@
# MockMvc against CustomerController: records in, records out, Jackson 3 in the middle
JSON library bean : tools.jackson.databind.json.JsonMapper
--- GET /customers/1 ---
{"id":1,"fullName":"Asha Mehta","email":"[email protected]","birthDate":"1990-06-15","age":36,"nickname":"anonymous","address":{"street":"12 Park Road","city":"Pune","zip":"411001","country":"IN"},"roles":["USER"],"attributes":{"newsletter":"yes"},"orders":[{"id":100,"status":"PAID","placedAt":"2026-02-01T10:00:00Z","lines":[{"sku":"BOOK-1","quantity":2,"unitPrice":12.50,"lineTotal":25.00},{"sku":"PEN-9","quantity":10,"unitPrice":0.75,"lineTotal":7.50}],"total":32.50}],"origin":"api"}
--- 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"}
--- PATCH /customers/1 {"nickname":"ash"} (every other component of the patch record is null) ---
{"id":1,"fullName":"Asha Mehta","email":"[email protected]","birthDate":"1990-06-15","age":36,"nickname":"ash","address":{"street":"12 Park Road","city":"Pune","zip":"411001","country":"IN"},"roles":["USER"],"attributes":{"newsletter":"yes"},"orders":[{"id":100,"status":"PAID","placedAt":"2026-02-01T10:00:00Z","lines":[{"sku":"BOOK-1","quantity":2,"unitPrice":12.50,"lineTotal":25.00},{"sku":"PEN-9","quantity":10,"unitPrice":0.75,"lineTotal":7.50}],"total":32.50}],"origin":"api"}
--- GET /customers/99 ---
status 404
@@ -0,0 +1,13 @@
# INDICATIVE timings: hand-written vs MapStruct vs Dozer (setter-based beans)
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.
@@ -0,0 +1,7 @@
# A record target component nobody mapped (unmappedTargetPolicy = ERROR)
$ javac -d target/broken/unmapped-target -cp $MAPSTRUCT_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/unmapped-target/*.java
src/broken/unmapped-target/BrokenMapper.java:12: error: Unmapped target property: "note".
Dst map(Src src);
^
1 error
@@ -0,0 +1,7 @@
# Two methods can produce the same property
$ javac -d target/broken/ambiguous -cp $MAPSTRUCT_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/ambiguous/*.java
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
@@ -0,0 +1,7 @@
# A typo in @Mapping(source = ...)
$ javac -d target/broken/missing-source -cp $MAPSTRUCT_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/missing-source/*.java
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
@@ -0,0 +1,15 @@
# @MappingTarget on a record compiles, with unmappedTargetPolicy = ERROR, and generates this
$ javac -d target/broken/update-record -s target/broken/update-record -cp $MAPSTRUCT_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/update-record/*.java
(exit status: 0)
$ cat target/broken/update-record/BrokenMapperImpl.java (header and imports trimmed)
public class BrokenMapperImpl implements BrokenMapper {
@Override
public void update(BrokenMapper.Patch patch, BrokenMapper.Dst target) {
if ( patch == null ) {
return;
}
}
}
@@ -0,0 +1,7 @@
# No way to turn one type into another
$ javac -d target/broken/cannot-map -cp $MAPSTRUCT_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/cannot-map/*.java
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
@@ -0,0 +1,10 @@
# @DecoratedWith with a constructor-injected delegate (componentModel = spring)
$ javac -d target/broken/decorator-constructor -cp $MAPSTRUCT_JAR:$SPRING_JARS -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/decorator-constructor/*.java
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
1 error
@@ -0,0 +1,24 @@
# The processor jar on the plain classpath: javac 25.0.3 compiles without generating anything
$ javac -d target/broken/processor-not-run -cp $MAPSTRUCT_JAR:$MAPSTRUCT_PROCESSOR_JAR src/broken/processor-not-run/UsesMapper.java
(exit status: 0)
$ ls target/broken/processor-not-run
UsesMapper$Dst.class
UsesMapper$Src.class
UsesMapper.class
$ java -cp $MAPSTRUCT_JAR:target/broken/processor-not-run UsesMapper
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
$ javac -proc:full -d target/broken/processor-not-run -cp $MAPSTRUCT_JAR:$MAPSTRUCT_PROCESSOR_JAR src/broken/processor-not-run/UsesMapper.java
(exit status: 0)
$ ls target/broken/processor-not-run
UsesMapper$Dst.class
UsesMapper$Src.class
UsesMapper.class
UsesMapperImpl.class
UsesMapperImpl.java
@@ -0,0 +1,25 @@
# Lombok + MapStruct + a record target: what the processor path order and lombok-mapstruct-binding change
--- MapStruct first, Lombok second, no binding ---
$ javac -d target/broken/lombok -cp $MAPSTRUCT_JAR:$LOMBOK_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR:$LOMBOK_JAR src/broken/lombok/LombokMapper.java
src\broken\lombok\LombokMapper.java:19: error: Unmapped target properties: "name, age".
PersonDto map(Person person);
^
1 error
(exit status: 1)
LombokMapperImpl generated: no
--- Lombok first, MapStruct second, no binding ---
$ javac -d target/broken/lombok -cp $MAPSTRUCT_JAR:$LOMBOK_JAR -processorpath $LOMBOK_JAR:$MAPSTRUCT_PROCESSOR_JAR src/broken/lombok/LombokMapper.java
(exit status: 0)
LombokMapperImpl generated: yes
--- MapStruct first, then Lombok and lombok-mapstruct-binding ---
$ javac -d target/broken/lombok -cp $MAPSTRUCT_JAR:$LOMBOK_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR:$LOMBOK_JAR:$BINDING_JAR src/broken/lombok/LombokMapper.java
(exit status: 0)
LombokMapperImpl generated: yes
--- Lombok, binding, then MapStruct ---
$ javac -d target/broken/lombok -cp $MAPSTRUCT_JAR:$LOMBOK_JAR -processorpath $LOMBOK_JAR:$BINDING_JAR:$MAPSTRUCT_PROCESSOR_JAR src/broken/lombok/LombokMapper.java
(exit status: 0)
LombokMapperImpl generated: yes
@@ -0,0 +1,20 @@
# mapstruct-processor 1.6.3: what the jar itself declares
--- META-INF/services/javax.annotation.processing.Processor (comments dropped) ---
org.mapstruct.ap.MappingProcessor
--- Implementation-Version in the manifest ---
Implementation-Version: 1.6.3
--- options the processor accepts (-Akey=value), from @SupportedOptions via javap ---
mapstruct.alwaysGenerateServicesFile
mapstruct.defaultComponentModel
mapstruct.defaultInjectionStrategy
mapstruct.disableBuilders
mapstruct.nullValueIterableMappingStrategy
mapstruct.nullValueMapMappingStrategy
mapstruct.suppressGeneratorTimestamp
mapstruct.suppressGeneratorVersionInfoComment
mapstruct.unmappedSourcePolicy
mapstruct.unmappedTargetPolicy
mapstruct.verbose
+80
View File
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>mapstruct</artifactId>
<version>1.0.0</version>
<name>mapstruct</name>
<description>MapStruct on Spring Boot 4: compile-time DTO mapping with Java records</description>
<properties>
<java.version>25</java.version>
<mapstruct.version>1.6.3</mapstruct.version>
<dozer.version>7.0.0</dozer.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Only the annotations live here. The code generator is the processor below. -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Only for the indicative timing comparison in the tests. Dozer is a runtime-reflection mapper. -->
<dependency>
<groupId>com.github.dozermapper</groupId>
<artifactId>dozer-core</artifactId>
<version>${dozer.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- The processor goes HERE and not in the dependencies. No Lombok in this module, so no
lombok-mapstruct-binding path is needed (the article says what happens when there is). -->
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Facts read straight out of the MapStruct processor jar (output/25).
set -euo pipefail
cd "$(dirname "$0")/.."
MS=${MAPSTRUCT_VERSION:-1.6.3}
JAR=$HOME/.m2/repository/org/mapstruct/mapstruct-processor/$MS/mapstruct-processor-$MS.jar
mkdir -p output target/facts
rm -rf target/facts/p && mkdir -p target/facts/p
unzip -o -q "$JAR" 'org/mapstruct/ap/MappingProcessor*' -d target/facts/p
{
echo "# mapstruct-processor $MS: what the jar itself declares"
echo
echo "--- META-INF/services/javax.annotation.processing.Processor (comments dropped) ---"
unzip -p "$JAR" META-INF/services/javax.annotation.processing.Processor | tr -d '\r' | grep -v '^#' | grep -v '^$'
echo
echo "--- Implementation-Version in the manifest ---"
unzip -p "$JAR" META-INF/MANIFEST.MF | tr -d '\r' | grep '^Implementation-Version'
echo
echo "--- options the processor accepts (-Akey=value), from @SupportedOptions via javap ---"
javap -v -cp target/facts/p org.mapstruct.ap.MappingProcessor | tr -d '\r' | grep -o 'Utf8 *mapstruct\.[A-Za-z.]*' | sed 's/Utf8 *//' | sort -u
} > output/25-processor-jar-facts.txt
echo "wrote output/25-processor-jar-facts.txt"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# Compiles the deliberately broken sources under src/broken/ with javac and commits the compiler's own words:
# 17 unmapped target property 18 ambiguous mapping methods 19 missing source property
# 20 @MappingTarget on a record (compiles!) 21 no way to map one type to another
# 22 @DecoratedWith + constructor 23 processor on the plain classpath (JDK 23+)
# 24 Lombok before/after MapStruct, with and without lombok-mapstruct-binding
# Each step also greps for the expected message and fails the script if it is not there, so a MapStruct
# upgrade that rewords an error is noticed. Needs a JDK 25 and the jars from one `mvn test` (they come from ~/.m2).
set -uo pipefail
cd "$(dirname "$0")/.."
mkdir -p output target/broken
M2=${M2_REPO:-$HOME/.m2/repository}
MS=${MAPSTRUCT_VERSION:-1.6.3}
LOMBOK=${LOMBOK_VERSION:-1.18.48}
SPRING=${SPRING_VERSION:-7.0.10}
native() { if command -v cygpath >/dev/null 2>&1; then cygpath -m "$1"; else echo "$1"; fi; }
case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) SEP=';' ;; *) SEP=':' ;; esac
MSJ=$(native "$M2/org/mapstruct/mapstruct/$MS/mapstruct-$MS.jar")
MSP=$(native "$M2/org/mapstruct/mapstruct-processor/$MS/mapstruct-processor-$MS.jar")
LOM=$(native "$M2/org/projectlombok/lombok/$LOMBOK/lombok-$LOMBOK.jar")
BIND=$(native "$M2/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar")
SPR="$(native "$M2/org/springframework/spring-context/$SPRING/spring-context-$SPRING.jar")$SEP$(native "$M2/org/springframework/spring-beans/$SPRING/spring-beans-$SPRING.jar")$SEP$(native "$M2/org/springframework/spring-core/$SPRING/spring-core-$SPRING.jar")"
fail=0
# capture <file> <title> <expected text or ''> <display command> -- <real command...>
capture() {
local file=$1 title=$2 expect=$3 shown=$4; shift 5
local out
out=$("$@" 2>&1 | sed 's/\r$//; s#\\#/#g')
{
echo "# $title"
echo
echo "\$ $shown"
printf '%s\n' "$out"
} > "output/$file"
if [ -n "$expect" ] && ! grep -qF -- "$expect" "output/$file"; then
echo "MISSING in output/$file: $expect" >&2
fail=1
fi
echo "wrote output/$file"
}
javac_case() { # <case dir> -> real javac invocation with the processor on -processorpath
local c=$1; rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
echo "javac -d target/broken/$c -cp \$MAPSTRUCT_JAR -processorpath \$MAPSTRUCT_PROCESSOR_JAR src/broken/$c/*.java"
}
for spec in \
"17-error-unmapped-target.txt|unmapped-target|Unmapped target property: \"note\"|A record target component nobody mapped (unmappedTargetPolicy = ERROR)" \
"18-error-ambiguous-mapping-methods.txt|ambiguous|Ambiguous mapping methods found for mapping property|Two methods can produce the same property" \
"19-error-missing-source-property.txt|missing-source|No property named \"nmae\" exists in source parameter(s)|A typo in @Mapping(source = ...)" \
"21-error-cannot-map-type.txt|cannot-map|Can't map property|No way to turn one type into another"; do
IFS='|' read -r file case expect title <<<"$spec"
shown=$(javac_case "$case")
capture "$file" "$title" "$expect" "$shown" -- \
javac -d "target/broken/$case" -cp "$MSJ" -processorpath "$MSP" src/broken/$case/*.java
done
# 20: @MappingTarget on a record is NOT a compile error: read what was generated
c=update-record; rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
{
echo "# @MappingTarget on a record compiles, with unmappedTargetPolicy = ERROR, and generates this"
echo
echo "\$ javac -d target/broken/$c -s target/broken/$c -cp \$MAPSTRUCT_JAR -processorpath \$MAPSTRUCT_PROCESSOR_JAR src/broken/$c/*.java"
javac -d "target/broken/$c" -s "target/broken/$c" -cp "$MSJ" -processorpath "$MSP" src/broken/$c/*.java 2>&1 | sed 's/␍$//'
echo "(exit status: ${PIPESTATUS[0]})"
echo
echo "\$ cat target/broken/$c/BrokenMapperImpl.java (header and imports trimmed)"
sed -n '/^public class/,$p' "target/broken/$c/BrokenMapperImpl.java" | sed 's/␍$//'
} > output/20-update-method-on-a-record-compiles-to-a-no-op.txt
echo "wrote output/20-update-method-on-a-record-compiles-to-a-no-op.txt"
grep -q 'public void update' output/20-update-method-on-a-record-compiles-to-a-no-op.txt || { echo "MISSING update in 20" >&2; fail=1; }
# 22: the decorator needs the Spring annotations on the classpath
c=decorator-constructor; rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
capture 22-error-decorator-constructor.txt "@DecoratedWith with a constructor-injected delegate (componentModel = spring)" \
"constructor BrokenMapperDecorator in class BrokenMapperDecorator cannot be applied to given types" \
"javac -d target/broken/$c -cp \$MAPSTRUCT_JAR:\$SPRING_JARS -processorpath \$MAPSTRUCT_PROCESSOR_JAR src/broken/$c/*.java" -- \
javac -d "target/broken/$c" -cp "$MSJ$SEP$SPR" -processorpath "$MSP" src/broken/$c/*.java
# 23: processor jar on the plain classpath, no -processorpath, no -proc:full
c=processor-not-run; rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
{
echo "# The processor jar on the plain classpath: javac $(javac -version 2>&1 | awk '{print $2}') compiles without generating anything"
echo
echo "\$ javac -d target/broken/$c -cp \$MAPSTRUCT_JAR:\$MAPSTRUCT_PROCESSOR_JAR src/broken/$c/UsesMapper.java"
javac -d "target/broken/$c" -cp "$MSJ$SEP$MSP" src/broken/$c/UsesMapper.java 2>&1 | sed 's/\r$//'
echo "(exit status: ${PIPESTATUS[0]})"
echo
echo "\$ ls target/broken/$c"
ls "target/broken/$c" | sed 's/\r$//'
echo
echo "\$ java -cp \$MAPSTRUCT_JAR:target/broken/$c UsesMapper"
java -cp "$MSJ$SEP""target/broken/$c" UsesMapper 2>&1 | sed 's/\r$//' | head -4
echo
rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
echo "\$ javac -proc:full -d target/broken/$c -cp \$MAPSTRUCT_JAR:\$MAPSTRUCT_PROCESSOR_JAR src/broken/$c/UsesMapper.java"
javac -proc:full -d "target/broken/$c" -cp "$MSJ$SEP$MSP" src/broken/$c/UsesMapper.java 2>&1 | sed 's/\r$//'
echo "(exit status: ${PIPESTATUS[0]})"
echo "\$ ls target/broken/$c"
ls "target/broken/$c" | sed 's/\r$//'
} > output/23-processor-on-the-plain-classpath.txt
echo "wrote output/23-processor-on-the-plain-classpath.txt"
grep -q 'UsesMapperImpl.class' output/23-processor-on-the-plain-classpath.txt || { echo "MISSING UsesMapperImpl in 23" >&2; fail=1; }
# 24: Lombok and MapStruct ordering
c=lombok
run_lombok() { # <label> <processorpath>
rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
echo
echo "--- $1 ---"
echo "\$ javac -d target/broken/$c -cp \$MAPSTRUCT_JAR:\$LOMBOK_JAR -processorpath $3 src/broken/$c/LombokMapper.java"
javac -d "target/broken/$c" -cp "$MSJ$SEP$LOM" -processorpath "$2" src/broken/$c/LombokMapper.java 2>&1 | sed 's/\r$//; s#\\#/#g' | grep -v '^WARNING:'
echo "(exit status: ${PIPESTATUS[0]})"
echo "LombokMapperImpl generated: $([ -f target/broken/$c/LombokMapperImpl.class ] && echo yes || echo no)"
}
{
echo "# Lombok + MapStruct + a record target: what the processor path order and lombok-mapstruct-binding change"
run_lombok "MapStruct first, Lombok second, no binding" "$MSP$SEP$LOM" '$MAPSTRUCT_PROCESSOR_JAR:$LOMBOK_JAR'
run_lombok "Lombok first, MapStruct second, no binding" "$LOM$SEP$MSP" '$LOMBOK_JAR:$MAPSTRUCT_PROCESSOR_JAR'
run_lombok "MapStruct first, then Lombok and lombok-mapstruct-binding" "$MSP$SEP$LOM$SEP$BIND" '$MAPSTRUCT_PROCESSOR_JAR:$LOMBOK_JAR:$BINDING_JAR'
run_lombok "Lombok, binding, then MapStruct" "$LOM$SEP$BIND$SEP$MSP" '$LOMBOK_JAR:$BINDING_JAR:$MAPSTRUCT_PROCESSOR_JAR'
} > output/24-lombok-processor-order.txt
echo "wrote output/24-lombok-processor-order.txt"
exit $fail
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Regenerates every file under output/.
#
# ./scripts/run-all.sh
#
# Needs a JDK 25 and Maven 3.9 (and network access to Maven Central once). Transcripts 01-15 and the timing
# comparison 16 are written by the test suite, so each figure in the article is an assertion that fails the
# build if it stops being true. 17-24 are the compiler's own words, captured from the broken sources in
# src/broken/; 25 is read out of the processor jar. 16 is a timing and therefore differs from run to run.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== test suite (transcripts 01-16)"
mvn -B test
echo "== compile errors and processor behaviour (17-24)"
./scripts/compile-errors.sh
echo "== processor jar facts (25)"
./scripts/capture-facts.sh
echo
echo "output:"
ls -1 output
@@ -0,0 +1,22 @@
import org.mapstruct.Mapper;
/** Two methods can turn a Money into a String, and nothing says which one a property should use. */
@Mapper
public interface BrokenMapper {
record Money(long cents) { }
record Src(Money price) { }
record Dst(String price) { }
Dst map(Src src);
default String plain(Money m) {
return String.valueOf(m.cents());
}
default String formatted(Money m) {
return m.cents() / 100 + "." + m.cents() % 100;
}
}
@@ -0,0 +1,18 @@
import org.mapstruct.Mapper;
/** MapStruct can map String to String and Tag to Tag-like beans, but not a Tag to a String. */
@Mapper
public interface BrokenMapper {
class Tag {
private final String label;
public Tag(String label) { this.label = label; }
public String getLabel() { return label; }
}
record Src(Tag tag) { }
record Dst(java.time.LocalDate tag) { }
Dst map(Src src);
}
@@ -0,0 +1,14 @@
import org.mapstruct.DecoratedWith;
import org.mapstruct.Mapper;
/** A decorator that wants its delegate through the constructor, with componentModel = "spring". */
@Mapper(componentModel = "spring")
@DecoratedWith(BrokenMapperDecorator.class)
public interface BrokenMapper {
record Src(String name) { }
record Dst(String name) { }
Dst map(Src src);
}
@@ -0,0 +1,13 @@
public abstract class BrokenMapperDecorator implements BrokenMapper {
private final BrokenMapper delegate;
protected BrokenMapperDecorator(BrokenMapper delegate) {
this.delegate = delegate;
}
@Override
public Dst map(Src src) {
return delegate.map(src);
}
}
@@ -0,0 +1,20 @@
import lombok.Getter;
import lombok.Setter;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
/** A Lombok source bean mapped to a record. Whether it compiles depends only on the processor path. */
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface LombokMapper {
@Getter
@Setter
class Person {
private String name;
private int age;
}
record PersonDto(String name, int age) { }
PersonDto map(Person person);
}
@@ -0,0 +1,14 @@
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
/** A typo in source = "...": the property does not exist on the source record. */
@Mapper
public interface BrokenMapper {
record Src(String name) { }
record Dst(String title) { }
@Mapping(source = "nmae", target = "title")
Dst map(Src src);
}
@@ -0,0 +1,17 @@
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
/** Compiled with the processor jar on the plain classpath, the way a pre-JDK-23 build was often set up. */
@Mapper
public interface UsesMapper {
record Src(String name) { }
record Dst(String name) { }
Dst map(Src src);
static void main(String[] args) {
System.out.println(Mappers.getMapper(UsesMapper.class).map(new Src("x")));
}
}
@@ -0,0 +1,13 @@
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
/** The target record has a component (note) that the source does not have. */
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface BrokenMapper {
record Src(String name, int qty) { }
record Dst(String name, int qty, String note) { }
Dst map(Src src);
}
@@ -0,0 +1,13 @@
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
/** An update method whose @MappingTarget is a record: there is nothing to update in place. */
@Mapper(unmappedTargetPolicy = org.mapstruct.ReportingPolicy.ERROR)
public interface BrokenMapper {
record Patch(String name) { }
record Dst(String name) { }
void update(Patch patch, @MappingTarget Dst target);
}
@@ -0,0 +1,15 @@
package com.ankurm.mapstruct;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Mappers never read the wall clock themselves; they are handed this one as a context parameter. */
@Configuration
public class ClockConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
}
@@ -0,0 +1,12 @@
package com.ankurm.mapstruct;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MapstructApplication {
public static void main(String[] args) {
SpringApplication.run(MapstructApplication.class, args);
}
}
@@ -0,0 +1,24 @@
package com.ankurm.mapstruct.domain;
/** A mutable entity-style class: no-arg constructor plus setters. MapStruct fills it through the setters. */
public class Address {
private String street;
private String city;
private String postalCode;
private String country;
public Address() { }
public Address(String street, String city, String postalCode, String country) {
this.street = street; this.city = city; this.postalCode = postalCode; this.country = country;
}
public String getStreet() { return street; }
public void setStreet(String street) { this.street = street; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getPostalCode() { return postalCode; }
public void setPostalCode(String postalCode) { this.postalCode = postalCode; }
public String getCountry() { return country; }
public void setCountry(String country) { this.country = country; }
}
@@ -0,0 +1,56 @@
package com.ankurm.mapstruct.domain;
import java.time.Instant;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** The persistence-side shape: mutable, with a field initialiser (tier) that a careless mapper can wipe out. */
public class Customer {
private Long id;
private String firstName;
private String lastName;
private String email;
private String passwordHash;
private LocalDate birthDate;
private String nickname;
private String tier = "STANDARD";
private Address address;
private Set<String> roles = new HashSet<>();
private Map<String, String> attributes = new LinkedHashMap<>();
private List<Order> orders;
private Instant createdAt;
private Instant updatedAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public LocalDate getBirthDate() { return birthDate; }
public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; }
public String getNickname() { return nickname; }
public void setNickname(String nickname) { this.nickname = nickname; }
public String getTier() { return tier; }
public void setTier(String tier) { this.tier = tier; }
public Address getAddress() { return address; }
public void setAddress(Address address) { this.address = address; }
public Set<String> getRoles() { return roles; }
public void setRoles(Set<String> roles) { this.roles = roles; }
public Map<String, String> getAttributes() { return attributes; }
public void setAttributes(Map<String, String> attributes) { this.attributes = attributes; }
public List<Order> getOrders() { return orders; }
public void setOrders(List<Order> orders) { this.orders = orders; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,26 @@
package com.ankurm.mapstruct.domain;
import java.time.Instant;
import java.util.List;
public class Order {
private Long id;
private OrderStatus status;
private Instant placedAt;
private List<OrderLine> lines;
public Order() { }
public Order(Long id, OrderStatus status, Instant placedAt, List<OrderLine> lines) {
this.id = id; this.status = status; this.placedAt = placedAt; this.lines = lines;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public OrderStatus getStatus() { return status; }
public void setStatus(OrderStatus status) { this.status = status; }
public Instant getPlacedAt() { return placedAt; }
public void setPlacedAt(Instant placedAt) { this.placedAt = placedAt; }
public List<OrderLine> getLines() { return lines; }
public void setLines(List<OrderLine> lines) { this.lines = lines; }
}
@@ -0,0 +1,22 @@
package com.ankurm.mapstruct.domain;
import java.math.BigDecimal;
public class OrderLine {
private String sku;
private int quantity;
private BigDecimal unitPrice;
public OrderLine() { }
public OrderLine(String sku, int quantity, BigDecimal unitPrice) {
this.sku = sku; this.quantity = quantity; this.unitPrice = unitPrice;
}
public String getSku() { return sku; }
public void setSku(String sku) { this.sku = sku; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }
public BigDecimal getUnitPrice() { return unitPrice; }
public void setUnitPrice(BigDecimal unitPrice) { this.unitPrice = unitPrice; }
}
@@ -0,0 +1,3 @@
package com.ankurm.mapstruct.domain;
public enum OrderStatus { NEW, PAID, SHIPPED, CANCELLED }
@@ -0,0 +1,4 @@
package com.ankurm.mapstruct.dto;
/** Note zip: the entity calls it postalCode, which is what makes the rename worth mapping. */
public record AddressDto(String street, String city, String zip, String country) { }
@@ -0,0 +1,20 @@
package com.ankurm.mapstruct.dto;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** What the API returns. A record: immutable, no setters; MapStruct builds it through its canonical constructor. */
public record CustomerDto(
Long id,
String fullName,
String email,
LocalDate birthDate,
int age,
String nickname,
AddressDto address,
Set<String> roles,
Map<String, String> attributes,
List<OrderDto> orders,
String origin) { }
@@ -0,0 +1,6 @@
package com.ankurm.mapstruct.dto;
import java.util.Set;
/** A partial update: every component may be null, and null is supposed to mean leave it alone. */
public record CustomerPatch(String email, String nickname, AddressDto address, Set<String> roles) { }
@@ -0,0 +1,15 @@
package com.ankurm.mapstruct.dto;
import java.time.LocalDate;
import java.util.Set;
/** What the API accepts when creating a customer. A record used as a mapping source. */
public record CustomerRequest(
String firstName,
String lastName,
String email,
LocalDate birthDate,
String nickname,
String tier,
AddressDto address,
Set<String> roles) { }
@@ -0,0 +1,5 @@
package com.ankurm.mapstruct.dto;
import java.math.BigDecimal;
public record InvoiceDto(Long id, String status, BigDecimal total, String display) { }
@@ -0,0 +1,7 @@
package com.ankurm.mapstruct.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public record OrderDto(Long id, String status, Instant placedAt, List<OrderLineDto> lines, BigDecimal total) { }
@@ -0,0 +1,5 @@
package com.ankurm.mapstruct.dto;
import java.math.BigDecimal;
public record OrderLineDto(String sku, int quantity, BigDecimal unitPrice, BigDecimal lineTotal) { }
@@ -0,0 +1,25 @@
package com.ankurm.mapstruct.dto;
import java.math.BigDecimal;
/**
* A record that ships its own builder. That is the one way MapStruct can run post-processing code
* against something still mutable: the hook receives the builder, not the finished record.
*/
public record ReceiptDto(Long orderId, BigDecimal total, String summary) {
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private Long orderId;
private BigDecimal total;
private String summary;
public Builder orderId(Long orderId) { this.orderId = orderId; return this; }
public Builder total(BigDecimal total) { this.total = total; return this; }
public Builder summary(String summary) { this.summary = summary; return this; }
public ReceiptDto build() { return new ReceiptDto(orderId, total, summary); }
}
}
@@ -0,0 +1,27 @@
package com.ankurm.mapstruct.mapper;
import com.ankurm.mapstruct.domain.Address;
import com.ankurm.mapstruct.dto.AddressDto;
import org.mapstruct.BeanMapping;
import org.mapstruct.InheritInverseConfiguration;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;
@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);
}
@@ -0,0 +1,17 @@
package com.ankurm.mapstruct.mapper;
import org.mapstruct.InjectionStrategy;
import org.mapstruct.MapperConfig;
import org.mapstruct.MappingConstants;
import org.mapstruct.ReportingPolicy;
/**
* One place for the three decisions every mapper in this module shares:
* Spring beans, constructor injection, and a compile error for any target property nobody mapped.
*/
@MapperConfig(
componentModel = MappingConstants.ComponentModel.SPRING,
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CentralConfig {
}
@@ -0,0 +1,82 @@
package com.ankurm.mapstruct.mapper;
import com.ankurm.mapstruct.domain.Customer;
import com.ankurm.mapstruct.dto.CustomerDto;
import com.ankurm.mapstruct.dto.CustomerPatch;
import com.ankurm.mapstruct.dto.CustomerRequest;
import java.time.Clock;
import java.time.LocalDate;
import java.time.Period;
import java.util.Locale;
import org.mapstruct.AfterMapping;
import org.mapstruct.BeanMapping;
import org.mapstruct.Context;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.Named;
import org.mapstruct.NullValueCheckStrategy;
import org.mapstruct.NullValuePropertyMappingStrategy;
@Mapper(config = CentralConfig.class, uses = {AddressMapper.class, OrderMapper.class})
public interface CustomerMapper {
// ---- entity -> 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) {
return birthDate == null ? 0 : Period.between(birthDate, LocalDate.now(clock)).getYears();
}
// ---- record -> entity (create) ----------------------------------------------------------------------
@Mapping(target = "id", ignore = true)
@Mapping(target = "passwordHash", ignore = true)
@Mapping(target = "attributes", ignore = true)
@Mapping(target = "orders", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
Customer toEntity(CustomerRequest request, @Context Clock clock);
/** Same mapping, but a null source property never overwrites what the entity already holds. */
@BeanMapping(nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
@Mapping(target = "id", ignore = true)
@Mapping(target = "passwordHash", ignore = true)
@Mapping(target = "attributes", ignore = true)
@Mapping(target = "orders", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
Customer toEntityKeepingDefaults(CustomerRequest request, @Context Clock clock);
/** Runs after every method that produces a Customer: normalises the e-mail and stamps the creation time. */
@AfterMapping
default void stamp(@MappingTarget Customer customer, @Context Clock clock) {
if (customer.getEmail() != null) {
customer.setEmail(customer.getEmail().trim().toLowerCase(Locale.ROOT));
}
customer.setCreatedAt(clock.instant());
}
// ---- record -> existing entity (patch) --------------------------------------------------------------
@BeanMapping(ignoreByDefault = true, nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
@Mapping(target = "email", source = "email")
@Mapping(target = "nickname", source = "nickname")
@Mapping(target = "address", source = "address")
@Mapping(target = "roles", source = "roles")
void patch(CustomerPatch patch, @MappingTarget Customer customer);
/** The same patch without the IGNORE strategy: MapStruct's default, where a null component overwrites. */
@BeanMapping(ignoreByDefault = true)
@Mapping(target = "email", source = "email")
@Mapping(target = "nickname", source = "nickname")
@Mapping(target = "address", source = "address")
@Mapping(target = "roles", source = "roles")
void replace(CustomerPatch patch, @MappingTarget Customer customer);
}
@@ -0,0 +1,17 @@
package com.ankurm.mapstruct.mapper;
import com.ankurm.mapstruct.domain.Order;
import com.ankurm.mapstruct.dto.InvoiceDto;
import org.mapstruct.DecoratedWith;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
/** Post-processing a record target by wrapping the generated mapper in a decorator. */
@Mapper(config = CentralConfig.class, uses = OrderMapper.class)
@DecoratedWith(InvoiceMapperDecorator.class)
public interface InvoiceMapper {
@Mapping(target = "total", source = "lines", qualifiedByName = "sumOfLines")
@Mapping(target = "display", ignore = true)
InvoiceDto toInvoice(Order order);
}
@@ -0,0 +1,32 @@
package com.ankurm.mapstruct.mapper;
import com.ankurm.mapstruct.domain.Order;
import com.ankurm.mapstruct.dto.InvoiceDto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
/**
* The generated InvoiceMapperImpl extends this class. The generated delegate is a second bean, InvoiceMapperImpl_,
* qualified "delegate". MapStruct 1.6.3 generates the subclass without a constructor, so this class needs a no-arg
* constructor and the delegate has to arrive by setter (or field) injection, not constructor injection.
* To "change" an immutable record the decorator has to build a new one.
*/
public abstract class InvoiceMapperDecorator implements InvoiceMapper {
private InvoiceMapper delegate;
@Autowired
public void setDelegate(@Qualifier("delegate") InvoiceMapper delegate) {
this.delegate = delegate;
}
@Override
public InvoiceDto toInvoice(Order order) {
InvoiceDto base = delegate.toInvoice(order);
if (base == null) {
return null;
}
return new InvoiceDto(base.id(), base.status(), base.total(),
"#" + base.id() + " [" + base.status() + "] " + base.total());
}
}
@@ -0,0 +1,21 @@
package com.ankurm.mapstruct.mapper;
import com.ankurm.mapstruct.domain.Order;
import com.ankurm.mapstruct.dto.OrderDto;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.NullValueMappingStrategy;
/**
* The same Order -> OrderDto mapping as OrderMapper, but a null Order, a null list of lines and a null map
* come out as a default object, an empty list and an empty map instead of null.
*/
@Mapper(config = CentralConfig.class, uses = OrderMapper.class,
nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT,
nullValueIterableMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT,
nullValueMapMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT)
public interface LenientOrderMapper {
@Mapping(target = "total", source = "lines", qualifiedByName = "sumOfLines")
OrderDto toDto(Order order);
}
@@ -0,0 +1,36 @@
package com.ankurm.mapstruct.mapper;
import com.ankurm.mapstruct.domain.Order;
import com.ankurm.mapstruct.domain.OrderLine;
import com.ankurm.mapstruct.dto.OrderDto;
import com.ankurm.mapstruct.dto.OrderLineDto;
import java.math.BigDecimal;
import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
@Mapper(config = CentralConfig.class)
public interface OrderMapper {
@Mapping(target = "lineTotal",
expression = "java(line.getUnitPrice().multiply(java.math.BigDecimal.valueOf(line.getQuantity())))")
OrderLineDto toDto(OrderLine line);
@Mapping(target = "total", source = "lines", qualifiedByName = "sumOfLines")
OrderDto toDto(Order order);
/** MapStruct writes the loop; this is only here so the List and the element mapping have a name. */
List<OrderDto> toDtos(List<Order> orders);
/** Not used by any implicit mapping: only a property that names it in qualifiedByName gets it. */
@Named("sumOfLines")
default BigDecimal sumOfLines(List<OrderLine> lines) {
if (lines == null) {
return BigDecimal.ZERO;
}
return lines.stream()
.map(l -> l.getUnitPrice().multiply(BigDecimal.valueOf(l.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
@@ -0,0 +1,24 @@
package com.ankurm.mapstruct.mapper;
import com.ankurm.mapstruct.domain.Order;
import com.ankurm.mapstruct.dto.ReceiptDto;
import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
/** Post-processing a record target through its builder. */
@Mapper(config = CentralConfig.class, uses = OrderMapper.class)
public interface ReceiptMapper {
@Mapping(target = "orderId", source = "id")
@Mapping(target = "total", source = "lines", qualifiedByName = "sumOfLines")
@Mapping(target = "summary", ignore = true)
ReceiptDto toReceipt(Order order);
/** The hook gets the builder. Once build() has run, the record can no longer be touched. */
@AfterMapping
default void summarise(Order order, @MappingTarget ReceiptDto.Builder builder) {
builder.summary("order " + order.getId() + ": " + order.getLines().size() + " line(s)");
}
}
@@ -0,0 +1,51 @@
package com.ankurm.mapstruct.web;
import com.ankurm.mapstruct.dto.CustomerDto;
import com.ankurm.mapstruct.dto.CustomerPatch;
import com.ankurm.mapstruct.dto.CustomerRequest;
import com.ankurm.mapstruct.mapper.CustomerMapper;
import java.time.Clock;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/customers")
public class CustomerController {
private final CustomerRepository repository;
private final CustomerMapper mapper;
private final Clock clock;
public CustomerController(CustomerRepository repository, CustomerMapper mapper, Clock clock) {
this.repository = repository;
this.mapper = mapper;
this.clock = clock;
}
@GetMapping("/{id}")
public CustomerDto get(@PathVariable Long id) {
return repository.findById(id).map(c -> mapper.toDto(c, clock)).orElseThrow(
() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CustomerDto create(@RequestBody CustomerRequest request) {
return mapper.toDto(repository.save(mapper.toEntity(request, clock)), clock);
}
@PatchMapping("/{id}")
public CustomerDto patch(@PathVariable Long id, @RequestBody CustomerPatch patch) {
var customer = repository.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
mapper.patch(patch, customer);
return mapper.toDto(repository.save(customer), clock);
}
}
@@ -0,0 +1,60 @@
package com.ankurm.mapstruct.web;
import com.ankurm.mapstruct.domain.Address;
import com.ankurm.mapstruct.domain.Customer;
import com.ankurm.mapstruct.domain.Order;
import com.ankurm.mapstruct.domain.OrderLine;
import com.ankurm.mapstruct.domain.OrderStatus;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Repository;
/** An in-memory stand-in for a JPA repository, seeded with one customer. */
@Repository
public class CustomerRepository {
private final Map<Long, Customer> store = new ConcurrentHashMap<>();
private final AtomicLong ids = new AtomicLong();
public CustomerRepository() {
save(sample());
}
public Optional<Customer> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public Customer save(Customer customer) {
if (customer.getId() == null) {
customer.setId(ids.incrementAndGet());
}
store.put(customer.getId(), customer);
return customer;
}
/** The customer every test and every transcript starts from. */
public static Customer sample() {
Customer c = new Customer();
c.setFirstName("Asha");
c.setLastName("Mehta");
c.setEmail("[email protected]");
c.setPasswordHash("{bcrypt}$2a$10$never-leaves-the-server");
c.setBirthDate(LocalDate.of(1990, 6, 15));
c.setNickname(null);
c.setAddress(new Address("12 Park Road", "Pune", "411001", null));
c.setRoles(new java.util.LinkedHashSet<>(Set.of("USER")));
c.getAttributes().put("newsletter", "yes");
c.setCreatedAt(Instant.parse("2026-01-01T00:00:00Z"));
c.setOrders(List.of(new Order(100L, OrderStatus.PAID, Instant.parse("2026-02-01T10:00:00Z"), List.of(
new OrderLine("BOOK-1", 2, new BigDecimal("12.50")),
new OrderLine("PEN-9", 10, new BigDecimal("0.75"))))));
return c;
}
}
@@ -0,0 +1,93 @@
package com.ankurm.mapstruct;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.mapstruct.domain.Customer;
import com.ankurm.mapstruct.probe.HandWritten;
import com.ankurm.mapstruct.probe.ViewMapper;
import com.ankurm.mapstruct.probe.ViewMapperImpl;
import com.ankurm.mapstruct.probe.Views.CustomerView;
import com.github.dozermapper.core.DozerBeanMapperBuilder;
import com.github.dozermapper.core.Mapper;
import java.util.List;
import java.util.function.Function;
import org.junit.jupiter.api.Test;
/**
* An INDICATIVE comparison: a hand-rolled warm-up, a few timed rounds, one JVM, one machine. It is not JMH.
* The numbers move from run to run; what is stable is the order of magnitude, so the test only asserts
* that all three mappers produce the same result.
*/
class BenchmarkTests {
private static volatile long sink;
private static double nsPerOp(Function<Customer, CustomerView> f, Customer c, int iterations) {
long start = System.nanoTime();
long acc = 0;
for (int i = 0; i < iterations; i++) {
acc += f.apply(c).getOrders().size();
}
sink += acc;
return (System.nanoTime() - start) / (double) iterations;
}
@Test
void handWrittenMapStructAndDozer() {
try (var t = new Transcript("16-indicative-timing-vs-handwritten-and-dozer.txt",
"INDICATIVE timings: hand-written vs MapStruct vs Dozer (setter-based beans)")) {
Customer c = Fixtures.customer();
ViewMapper mapStruct = new ViewMapperImpl();
Mapper dozer = DozerBeanMapperBuilder.buildDefault();
long first = System.nanoTime();
CustomerView viaDozer = dozer.map(c, CustomerView.class);
double dozerFirstMs = (System.nanoTime() - first) / 1e6;
first = System.nanoTime();
CustomerView viaMapStruct = mapStruct.toView(c);
double mapStructFirstMs = (System.nanoTime() - first) / 1e6;
CustomerView viaHand = HandWritten.toView(c);
Function<Customer, CustomerView> hand = HandWritten::toView;
Function<Customer, CustomerView> ms = mapStruct::toView;
Function<Customer, CustomerView> dz = x -> dozer.map(x, CustomerView.class);
for (var f : List.of(hand, ms, dz)) {
nsPerOp(f, c, 50_000); // warm-up
}
int n = 200_000;
double h = Double.MAX_VALUE, m = Double.MAX_VALUE, d = Double.MAX_VALUE;
for (int round = 0; round < 5; round++) {
h = Math.min(h, nsPerOp(hand, c, n));
m = Math.min(m, nsPerOp(ms, c, n));
d = Math.min(d, nsPerOp(dz, c, n));
}
boolean equal = sameShape(viaHand, viaMapStruct) && sameShape(viaHand, viaDozer);
t.line("Java %s, one JVM, %d iterations per round, best of 5 rounds after a 50000-iteration warm-up", Runtime.version(), n);
t.line("mapping: Customer -> CustomerView (1 address, 1 order with 2 lines, 1 set, 1 map)");
t.blank();
t.line("%-16s %10.0f ns/op", "hand-written", h);
t.line("%-16s %10.0f ns/op (%.1fx hand-written)", "MapStruct 1.6.3", m, m / h);
t.line("%-16s %10.0f ns/op (%.0fx hand-written)", "Dozer 7.0.0", d, d / h);
t.blank();
t.line("first call, cold: MapStruct %.2f ms, Dozer %.1f ms", mapStructFirstMs, dozerFirstMs);
t.line("results equal: %s", equal);
t.blank();
t.line("Indicative only. Timings vary between runs and machines; do not quote them as a benchmark result.");
assertThat(equal).isTrue();
assertThat(sink).isPositive();
}
}
private static boolean sameShape(CustomerView a, CustomerView b) {
var la = a.getOrders().get(0).getLines().get(1);
var lb = b.getOrders().get(0).getLines().get(1);
return a.getFirstName().equals(b.getFirstName()) && a.getTier().equals(b.getTier())
&& a.getAddress().getPostalCode().equals(b.getAddress().getPostalCode())
&& a.getRoles().equals(b.getRoles()) && a.getAttributes().equals(b.getAttributes())
&& a.getOrders().get(0).getStatus().equals(b.getOrders().get(0).getStatus())
&& la.getUnitPrice().compareTo(lb.getUnitPrice()) == 0 && la.getQuantity() == lb.getQuantity();
}
}
@@ -0,0 +1,55 @@
package com.ankurm.mapstruct;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
import com.ankurm.mapstruct.mapper.CustomerMapper;
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
import java.time.Clock;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
/** @SpringJUnitConfig is @ContextConfiguration + the JUnit extension: a context made of just the generated classes. */
@SpringJUnitConfig(classes = {CustomerMapperImpl.class, AddressMapperImpl.class, OrderMapperImpl.class,
ContextConfigurationMapperTest.PinnedClock.class})
class ContextConfigurationMapperTest {
@Configuration
static class PinnedClock {
@Bean
Clock clock() {
return Fixtures.clock();
}
}
@Autowired
ConfigurableApplicationContext context;
@Autowired
CustomerMapper mapper;
@Autowired
Clock clock;
@Test
void aContextOfOnlyTheGeneratedClasses() {
try (var t = new Transcript("12-testing-mappers-contextconfiguration.txt",
"@SpringJUnitConfig(classes = {the generated impls}): only the beans the mapper needs")) {
int definitions = context.getBeanDefinitionCount();
String name = mapper.toDto(Fixtures.customer(), clock).fullName();
t.line("bean definitions in the context : %d", definitions);
t.line("bean names : %s", String.join(", ", context.getBeanDefinitionNames()));
t.line("mapper injected as : %s", mapper.getClass().getSimpleName());
t.line("mapped : %s", name);
assertThat(definitions).isLessThan(20);
assertThat(mapper).isInstanceOf(CustomerMapperImpl.class);
assertThat(name).isEqualTo("Asha Mehta");
}
}
}
@@ -0,0 +1,70 @@
package com.ankurm.mapstruct;
import com.ankurm.mapstruct.domain.Customer;
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
import com.ankurm.mapstruct.web.CustomerRepository;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
/** Shared test data and two helpers: a pinned clock, and a reader for the sources MapStruct generated. */
public final class Fixtures {
private Fixtures() { }
/** 2026-11-05T00:00:00Z, so ages and creation timestamps are the same on every run. */
public static Clock clock() {
return Clock.fixed(Instant.parse("2026-11-05T00:00:00Z"), ZoneOffset.UTC);
}
public static Customer customer() {
return CustomerRepository.sample();
}
/** What Spring would do with constructor injection, without Spring. */
public static CustomerMapperImpl customerMapper() {
return new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl());
}
/**
* The generated source for a mapper, trimmed to the class itself: no package, imports or the
* {@code @Generated} annotation (whose date changes on every build).
*/
public static String generated(String root, String fqcn) {
Path file = Path.of("target", root, fqcn.replace('.', '/') + ".java");
try {
String src = Files.readString(file).replace("\r\n", "\n");
int start = src.indexOf("@Component");
if (start < 0) {
start = src.indexOf("public class");
}
return src.substring(start);
} catch (IOException e) {
throw new IllegalStateException("no generated source at " + file, e);
}
}
public static String generatedMain(String fqcn) {
return generated("generated-sources/annotations", fqcn);
}
public static String generatedTest(String fqcn) {
return generated("generated-test-sources/test-annotations", fqcn);
}
/** One method out of generated source: its signature line down to the closing brace at four spaces of indent. */
public static String method(String source, String signatureStart) {
int i = source.indexOf(signatureStart);
if (i < 0) {
throw new IllegalStateException("no method starting with " + signatureStart);
}
int lineStart = source.lastIndexOf('\n', i) + 1;
int end = source.indexOf("\n }\n", i);
return source.substring(lineStart, end + "\n }".length());
}
}
@@ -0,0 +1,159 @@
package com.ankurm.mapstruct;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.ankurm.mapstruct.domain.Order;
import com.ankurm.mapstruct.dto.InvoiceDto;
import com.ankurm.mapstruct.dto.ReceiptDto;
import com.ankurm.mapstruct.mapper.InvoiceMapper;
import com.ankurm.mapstruct.mapper.InvoiceMapperImpl;
import com.ankurm.mapstruct.mapper.InvoiceMapperImpl_;
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
import com.ankurm.mapstruct.mapper.ReceiptMapperImpl;
import com.ankurm.mapstruct.probe.Probes;
import com.ankurm.mapstruct.probe.CycleContext;
import com.ankurm.mapstruct.probe.CycleMapperImpl;
import com.ankurm.mapstruct.probe.FactoryTrapMapperImpl;
import com.ankurm.mapstruct.probe.HookOrderMapperImpl;
import com.ankurm.mapstruct.probe.Probes.Employee;
import java.util.Arrays;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
/** @BeforeMapping, @AfterMapping, @ObjectFactory, builders, decorators and @Context - and what records change. */
@SpringJUnitConfig(classes = {InvoiceMapperImpl.class, InvoiceMapperImpl_.class, OrderMapperImpl.class})
class HooksTests {
@Autowired
ApplicationContext context;
@Autowired
InvoiceMapper invoiceMapper;
@BeforeEach
void clear() {
Probes.TRACE.clear();
}
@Test
void beforeMappingWithAMappingTargetRunsAfterTheRecordIsBuilt() {
try (var t = new Transcript("07-hooks-on-records.txt",
"@BeforeMapping / @AfterMapping on a record target versus a bean target (call order, recorded)")) {
var mapper = new HookOrderMapperImpl();
mapper.toRecord(new Probes.Pair("pen", 3));
t.section("target is a record: toRecord(Pair)");
Probes.TRACE.forEach(t::line);
var recordTrace = java.util.List.copyOf(Probes.TRACE);
Probes.TRACE.clear();
mapper.toBean(new Probes.Pair("pen", 3));
t.section("target is a mutable bean: toBean(Pair)");
Probes.TRACE.forEach(t::line);
var beanTrace = java.util.List.copyOf(Probes.TRACE);
String generated = Fixtures.generatedTest("com.ankurm.mapstruct.probe.HookOrderMapperImpl");
t.section("the generated toRecord, which is why");
t.block(Fixtures.method(generated, "toRecord("));
assertThat(recordTrace).containsExactly(
"@BeforeMapping(source)",
"@BeforeMapping(source, @MappingTarget Boxed) target=Boxed[name=pen, qty=3]",
"@AfterMapping(source, @MappingTarget Boxed) target=Boxed[name=pen, qty=3]",
"@AfterMapping(source)");
assertThat(beanTrace).containsExactly(
"@BeforeMapping(source)",
"@BeforeMapping(source, @MappingTarget BoxedBean) name=null",
" BoxedBean.setName(pen)",
" BoxedBean.setQty(3)",
"@AfterMapping(source, @MappingTarget BoxedBean) name=pen",
"@AfterMapping(source)");
}
}
@Test
void anObjectFactoryThatReturnsARecordSilentlyDropsEveryProperty() {
try (var t = new Transcript("08-object-factory-trap.txt",
"@ObjectFactory returning a record: the factory's object is the result, and nothing is copied into it")) {
var mapper = new FactoryTrapMapperImpl();
var result = mapper.toRecord(new Probes.Pair("pen", 3));
t.line("source : %s", new Probes.Pair("pen", 3));
t.line("result : %s", result);
t.line("compiled with unmappedTargetPolicy = ERROR, and no error was reported");
t.section("the generated method");
t.block(Fixtures.method(Fixtures.generatedTest("com.ankurm.mapstruct.probe.FactoryTrapMapperImpl"),
"toRecord("));
assertThat(result).isEqualTo(new Probes.Boxed("from-the-factory", 0));
}
}
@Test
void aBuilderIsTheWayToPostProcessARecord() {
try (var t = new Transcript("09-builder-and-decorator.txt",
"Post-processing a record: @AfterMapping on its builder, or a @DecoratedWith decorator")) {
Order order = Fixtures.customer().getOrders().get(0);
var receiptMapper = new ReceiptMapperImpl(new OrderMapperImpl());
ReceiptDto receipt = receiptMapper.toReceipt(order);
t.section("ReceiptMapper: the hook receives ReceiptDto.Builder");
t.line("%s", receipt);
t.block(Fixtures.method(Fixtures.generatedMain("com.ankurm.mapstruct.mapper.ReceiptMapperImpl"),
"public ReceiptDto toReceipt("));
InvoiceDto invoice = invoiceMapper.toInvoice(order);
InvoiceDto raw = context.getBean(InvoiceMapperImpl_.class).toInvoice(order);
t.section("InvoiceMapper: @DecoratedWith, so two beans implement the interface");
for (String name : context.getBeanNamesForType(InvoiceMapper.class)) {
t.line("bean %-20s -> %s", name, context.getBean(name).getClass().getSimpleName());
}
t.line("injected InvoiceMapper (the @Primary one) : %s", invoice);
t.line("the generated delegate on its own : %s", raw);
t.section("the generated class that decorates");
t.block(Fixtures.generatedMain("com.ankurm.mapstruct.mapper.InvoiceMapperImpl").stripTrailing());
assertThat(receipt.summary()).isEqualTo("order 100: 2 line(s)");
assertThat(receipt.orderId()).isEqualTo(100L);
assertThat(context.getBeanNamesForType(InvoiceMapper.class)).hasSize(2);
assertThat(invoiceMapper).isInstanceOf(InvoiceMapperImpl.class);
assertThat(invoice.display()).isEqualTo("#100 [PAID] 32.50");
assertThat(raw.display()).isNull();
}
}
@Test
void recordsCannotRepresentACycleEvenWithTheContextRecipe() {
try (var t = new Transcript("10-context-and-cycles.txt",
"@Context cycle tracking: it works for a bean target and cannot work for a record target")) {
Employee ann = new Employee("Ann");
Employee bob = new Employee("Bob");
ann.manager = bob;
bob.manager = ann;
var mapper = new CycleMapperImpl();
var bean = mapper.toBean(ann, new CycleContext());
t.line("bean target + @Context CycleContext : ann.manager.manager is ann? %s", bean.getManager().getManager() == bean);
Throwable thrown = null;
try {
mapper.toRecord(ann, new CycleContext());
} catch (StackOverflowError e) {
thrown = e;
}
t.line("record target + the same @Context : %s", thrown == null ? "mapped" : thrown.getClass().getName());
t.section("why: the generated record mapping recurses BEFORE it can call the constructor");
t.block(Fixtures.method(Fixtures.generatedTest("com.ankurm.mapstruct.probe.CycleMapperImpl"),
"toRecord("));
assertThat(bean.getManager().getManager()).isSameAs(bean);
assertThat(thrown).isInstanceOf(StackOverflowError.class);
assertThatThrownBy(() -> mapper.toRecord(ann, new CycleContext())).isInstanceOf(StackOverflowError.class);
assertThat(Arrays.stream(Probes.EmployeeRecord.class.getRecordComponents()).count()).isEqualTo(2);
}
}
}
@@ -0,0 +1,162 @@
package com.ankurm.mapstruct;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.mapstruct.domain.Customer;
import com.ankurm.mapstruct.dto.AddressDto;
import com.ankurm.mapstruct.dto.CustomerDto;
import com.ankurm.mapstruct.dto.CustomerRequest;
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
/** Records as source and target, the generated code, and the @Mapping options that shape a mapping. */
class MappingBasicsTests {
private final CustomerMapperImpl mapper = Fixtures.customerMapper();
@Test
void recordsAreBuiltThroughTheirConstructorAndNeverThroughSetters() {
try (var t = new Transcript("01-records-as-source-and-target.txt",
"A record target has no setters: MapStruct calls the canonical constructor")) {
long setters = Arrays.stream(CustomerDto.class.getDeclaredMethods())
.filter(m -> m.getName().startsWith("set")).count();
long customerSetters = Arrays.stream(Customer.class.getDeclaredMethods())
.filter(m -> m.getName().startsWith("set")).count();
t.line("CustomerDto is a record : %s", CustomerDto.class.isRecord());
t.line("setters on CustomerDto : %d", setters);
t.line("setters on Customer (entity) : %d", customerSetters);
String source = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.CustomerMapperImpl");
String toDto = Fixtures.method(source, "public CustomerDto toDto(");
String ctorLine = toDto.lines().filter(l -> l.contains("new CustomerDto(")).findFirst().orElseThrow().strip();
t.section("the line the generated toDto uses to build the record");
t.line(ctorLine);
String toEntity = Fixtures.method(source, "public Customer toEntity(");
t.section("record as SOURCE: the generated toEntity reads accessors, not getters");
toEntity.lines().filter(l -> l.contains("request.")).limit(4).forEach(l -> t.line(l.strip()));
assertThat(CustomerDto.class.isRecord()).isTrue();
assertThat(setters).isZero();
assertThat(customerSetters).isGreaterThan(10);
assertThat(ctorLine).startsWith("CustomerDto customerDto = new CustomerDto( id, fullName,");
assertThat(toEntity).contains("request.firstName()").doesNotContain("getFirstName");
}
}
@Test
void theGeneratedImplementationIsPlainJavaYouCanRead() {
try (var t = new Transcript("02-generated-implementation.txt",
"What MapStruct 1.6.3 generated, read from target/generated-sources (header and imports trimmed)")) {
String address = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.AddressMapperImpl");
t.section("AddressMapperImpl");
t.block(address);
String customer = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.CustomerMapperImpl");
t.section("CustomerMapperImpl: constructor injection, then toDto");
t.block(customer.substring(0, customer.indexOf(" @Override")).stripTrailing());
t.blank();
t.block(Fixtures.method(customer, "public CustomerDto toDto("));
assertThat(address).contains("@Component").contains("public class AddressMapperImpl implements AddressMapper");
assertThat(customer).contains("@Autowired").contains("public CustomerMapperImpl(AddressMapper addressMapper, OrderMapper orderMapper)");
assertThat(customer).doesNotContain("reflect");
}
}
@Test
void entityToRecordWithNestedObjectsAndCollections() {
try (var t = new Transcript("03-entity-to-record.txt",
"Customer (mutable entity) -> CustomerDto (record), with nested Address, a List of Orders, a Set and a Map")) {
Customer customer = Fixtures.customer();
CustomerDto dto = mapper.toDto(customer, Fixtures.clock());
t.line("fullName : %s <- expression = java(first + \" \" + last)", dto.fullName());
t.line("age : %d <- qualifiedByName, with the clock passed as @Context", dto.age());
t.line("nickname : %s <- source was null, defaultValue = \"anonymous\"", dto.nickname());
t.line("origin : %s <- constant = \"api\"", dto.origin());
t.line("address : %s <- postalCode renamed to zip; null country got defaultValue IN", dto.address());
t.line("roles : %s same instance as the entity's? %s", dto.roles(), dto.roles() == customer.getRoles());
t.line("attributes : %s same instance as the entity's? %s", dto.attributes(), dto.attributes() == customer.getAttributes());
t.section("List<Order> -> List<OrderDto>, with a computed total");
var order = dto.orders().get(0);
t.line("order id=%d status=%s total=%s", order.id(), order.status(), order.total());
order.lines().forEach(l -> t.line(" %s", l));
t.section("what is NOT in the record");
t.line("passwordHash is a component of CustomerDto? %s", Arrays.stream(CustomerDto.class.getRecordComponents())
.anyMatch(c -> c.getName().equals("passwordHash")));
assertThat(dto.fullName()).isEqualTo("Asha Mehta");
assertThat(dto.age()).isEqualTo(36);
assertThat(dto.nickname()).isEqualTo("anonymous");
assertThat(dto.origin()).isEqualTo("api");
assertThat(dto.address()).isEqualTo(new AddressDto("12 Park Road", "Pune", "411001", "IN"));
assertThat(dto.roles()).isEqualTo(Set.of("USER")).isNotSameAs(customer.getRoles());
assertThat(dto.attributes()).containsEntry("newsletter", "yes").isNotSameAs(customer.getAttributes());
assertThat(order.status()).isEqualTo("PAID");
assertThat(order.total()).isEqualByComparingTo(new BigDecimal("32.50"));
assertThat(order.lines()).extracting("lineTotal").usingComparatorForType(BigDecimal::compareTo, BigDecimal.class)
.containsExactly(new BigDecimal("25.00"), new BigDecimal("7.50"));
}
}
@Test
void recordToEntityAndTheInverseConfiguration() {
try (var t = new Transcript("04-record-to-entity.txt",
"CustomerRequest (record) -> Customer (entity); AddressMapper.toEntity inherits the rename in reverse")) {
var request = new CustomerRequest("Ravi", "Iyer", " [email protected] ", LocalDate.of(1985, 1, 2),
null, null, new AddressDto("1 Lake View", "Chennai", "600001", null), Set.of("ADMIN"));
Customer c = mapper.toEntity(request, Fixtures.clock());
t.line("email after @AfterMapping : '%s' (request carried '%s')", c.getEmail(), request.email());
t.line("createdAt (from @Context) : %s", c.getCreatedAt());
t.line("address.postalCode : %s <- @InheritInverseConfiguration turned zip back into postalCode", c.getAddress().getPostalCode());
t.line("address.country : %s <- defaultValue is NOT inverted; toDto gives IN, toEntity does not", c.getAddress().getCountry());
t.line("tier : %s <- the entity said STANDARD; the request said null", c.getTier());
Customer kept = mapper.toEntityKeepingDefaults(request, Fixtures.clock());
t.line("tier with nullValueCheckStrategy = ALWAYS : %s", kept.getTier());
t.line("id / passwordHash / orders : %s / %s / %s <- ignore = true", c.getId(), c.getPasswordHash(), c.getOrders());
t.section("round trip: entity -> record -> entity");
Customer original = Fixtures.customer();
Customer back = mapper.toEntity(new CustomerRequest(original.getFirstName(), original.getLastName(),
original.getEmail(), original.getBirthDate(), original.getNickname(), original.getTier(),
new AddressMapperImpl().toDto(original.getAddress()), original.getRoles()), Fixtures.clock());
t.line("passwordHash survives the round trip? %s", back.getPasswordHash() != null);
t.line("address country survives? %s -> %s", original.getAddress().getCountry(), back.getAddress().getCountry());
assertThat(c.getEmail()).isEqualTo("[email protected]");
assertThat(c.getCreatedAt()).isEqualTo(Fixtures.clock().instant());
assertThat(c.getAddress().getPostalCode()).isEqualTo("600001");
assertThat(c.getAddress().getCountry()).isNull();
assertThat(c.getTier()).isNull();
assertThat(kept.getTier()).isEqualTo("STANDARD");
assertThat(c.getId()).isNull();
assertThat(back.getPasswordHash()).isNull();
assertThat(back.getAddress().getCountry()).isEqualTo("IN");
}
}
@Test
void generatedAddressInverseHasNoDefaultValue() {
String address = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.AddressMapperImpl");
assertThat(Fixtures.method(address, "public Address toEntity(")).doesNotContain("\"IN\"");
assertThat(Fixtures.method(address, "public AddressDto toDto(")).contains("\"IN\"");
}
@Test
void listsAreCopiedNotShared() {
Customer customer = Fixtures.customer();
CustomerDto dto = mapper.toDto(customer, Fixtures.clock());
assertThat(dto.orders()).isNotSameAs(customer.getOrders());
List<?> before = List.copyOf(dto.orders());
customer.getOrders().get(0).setId(999L);
assertThat(dto.orders()).isEqualTo(before);
}
}
@@ -0,0 +1,121 @@
package com.ankurm.mapstruct;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.ankurm.mapstruct.domain.Address;
import com.ankurm.mapstruct.domain.Customer;
import com.ankurm.mapstruct.domain.Order;
import com.ankurm.mapstruct.dto.AddressDto;
import com.ankurm.mapstruct.dto.CustomerPatch;
import com.ankurm.mapstruct.dto.OrderDto;
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
import com.ankurm.mapstruct.mapper.LenientOrderMapperImpl;
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
import java.util.Set;
import org.junit.jupiter.api.Test;
/** Null strategies (property level and whole-object level) and what @MappingTarget updates really do. */
class NullHandlingAndUpdateTests {
private final CustomerMapperImpl mapper = Fixtures.customerMapper();
private static String describe(Customer c) {
Address a = c.getAddress();
return "email=" + c.getEmail() + ", nickname=" + c.getNickname() + ", roles=" + c.getRoles()
+ ", address=" + (a == null ? "null" : a.getStreet() + "/" + a.getCity() + "/" + a.getPostalCode());
}
@Test
void nullPropertyStrategyDecidesWhetherAPatchErasesData() {
try (var t = new Transcript("05-null-strategies.txt",
"Null handling: IGNORE vs the default on a patch, and RETURN_DEFAULT on whole objects and collections")) {
var allNull = new CustomerPatch(null, null, null, null);
Customer ignoring = Fixtures.customer();
ignoring.setNickname("ash");
t.section("patch(): nullValuePropertyMappingStrategy = IGNORE, applied with a patch whose components are all null");
t.line("before: %s", describe(ignoring));
mapper.patch(allNull, ignoring);
t.line("after : %s", describe(ignoring));
Customer replacing = Fixtures.customer();
replacing.setNickname("ash");
t.section("replace(): the default strategy (SET_TO_NULL), same all-null patch");
t.line("before: %s", describe(replacing));
mapper.replace(allNull, replacing);
t.line("after : %s", describe(replacing));
assertThat(describe(ignoring)).isEqualTo("[email protected], nickname=ash, roles=[USER], address=12 Park Road/Pune/411001");
assertThat(replacing.getEmail()).isNull();
assertThat(replacing.getNickname()).isNull();
assertThat(replacing.getRoles()).isNull();
assertThat(replacing.getAddress()).isNull();
var strict = new OrderMapperImpl();
var lenient = new LenientOrderMapperImpl(strict);
Order noLines = new Order(7L, null, null, null);
t.section("whole objects and collections: OrderMapper (default) vs LenientOrderMapper (RETURN_DEFAULT)");
t.line("strict.toDto(null) : %s", strict.toDto((Order) null));
t.line("lenient.toDto(null) : %s", lenient.toDto(null));
OrderDto strictNoLines = strict.toDto(noLines);
OrderDto lenientNoLines = lenient.toDto(noLines);
t.line("strict order, lines = null : lines=%s", strictNoLines.lines());
t.line("lenient order, lines = null : lines=%s", lenientNoLines.lines());
t.line("strict.toDtos(null) : %s", strict.toDtos(null));
assertThat(strict.toDto((Order) null)).isNull();
assertThat(lenient.toDto(null)).isNotNull();
assertThat(lenient.toDto(null).lines()).isNull();
assertThat(strictNoLines.lines()).isNull();
assertThat(lenientNoLines.lines()).isNotNull().isEmpty();
assertThat(strict.toDtos(null)).isNull();
String generated = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.CustomerMapperImpl");
t.section("the generated patch() (IGNORE): every assignment is guarded");
t.block(Fixtures.method(generated, "public void patch("));
assertThat(Fixtures.method(generated, "public void patch(")).contains("if ( patch.email() != null )");
}
}
@Test
void updatesMutateTheTargetInPlaceIncludingItsCollections() {
try (var t = new Transcript("06-update-in-place.txt",
"@MappingTarget updates: nested objects and collections are changed in place")) {
Customer c = Fixtures.customer();
Address addressBefore = c.getAddress();
Set<String> rolesBefore = c.getRoles();
mapper.patch(new CustomerPatch(null, null, new AddressDto(null, "Mumbai", null, null), Set.of("USER", "ADMIN")), c);
t.section("patch only the city and the roles");
t.line("address: %s", describe(c));
t.line("same Address instance as before? %s <- AddressMapper.update was called on it", c.getAddress() == addressBefore);
t.line("same roles Set instance as before? %s <- cleared and refilled, not replaced", c.getRoles() == rolesBefore);
Customer shared = Fixtures.customer();
Set<String> alias = shared.getRoles();
mapper.patch(new CustomerPatch(null, null, null, Set.of("AUDITOR")), shared);
t.line("another holder of the old roles Set now sees: %s", alias);
Customer frozen = Fixtures.customer();
frozen.setRoles(Set.of("USER"));
t.section("an immutable collection on the target");
Throwable thrown = null;
try {
mapper.patch(new CustomerPatch(null, null, null, Set.of("ADMIN")), frozen);
} catch (RuntimeException e) {
thrown = e;
}
t.line("patching roles on a Set.of(...) target threw: %s", thrown == null ? "nothing" : thrown.getClass().getName());
assertThat(c.getAddress()).isSameAs(addressBefore);
assertThat(c.getAddress().getStreet()).isEqualTo("12 Park Road");
assertThat(c.getAddress().getCity()).isEqualTo("Mumbai");
assertThat(c.getRoles()).isSameAs(rolesBefore).containsExactlyInAnyOrder("USER", "ADMIN");
assertThat(alias).containsExactly("AUDITOR");
assertThat(thrown).isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> mapper.patch(new CustomerPatch(null, null, null, Set.of("X")), frozen))
.isInstanceOf(UnsupportedOperationException.class);
}
}
}
@@ -0,0 +1,41 @@
package com.ankurm.mapstruct;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.mapstruct.mapper.CustomerMapper;
import com.ankurm.mapstruct.web.CustomerController;
import java.time.Clock;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ConfigurableApplicationContext;
/** The whole application: every bean, every auto-configuration. Correct, and far more than a mapper test needs. */
@SpringBootTest
class SpringBootMapperTest {
@Autowired
ConfigurableApplicationContext context;
@Autowired
CustomerMapper mapper;
@Autowired
Clock clock;
@Test
void theWholeApplicationContext() {
try (var t = new Transcript("13-testing-mappers-springboottest.txt",
"@SpringBootTest: the same mapper, inside the whole application")) {
int definitions = context.getBeanDefinitionCount();
String name = mapper.toDto(Fixtures.customer(), Fixtures.clock()).fullName();
t.line("bean definitions in the context : %d", definitions);
t.line("includes the web controller : %s", context.getBeanNamesForType(CustomerController.class).length == 1);
t.line("clock is the application's own : %s", clock.getZone());
t.line("mapped : %s", name);
assertThat(definitions).isGreaterThan(100);
assertThat(name).isEqualTo("Asha Mehta");
}
}
}
@@ -0,0 +1,124 @@
package com.ankurm.mapstruct;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.ankurm.mapstruct.domain.Customer;
import com.ankurm.mapstruct.dto.AddressDto;
import com.ankurm.mapstruct.dto.CustomerDto;
import com.ankurm.mapstruct.mapper.AddressMapper;
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
import com.ankurm.mapstruct.mapper.CustomerMapper;
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
import java.time.Clock;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.function.Consumer;
import java.util.function.Function;
import org.junit.jupiter.api.Test;
import org.mapstruct.factory.Mappers;
/** Four ways to test a generated mapper, and one way to make a new DTO component fail the build until it is tested. */
class TestingStrategiesTests {
// ---- 1. plain unit tests ---------------------------------------------------------------------------
@Test
void plainUnitTests() {
try (var t = new Transcript("11-testing-mappers-plain.txt", "Testing a generated mapper without Spring: Mappers.getMapper versus constructing the Impl")) {
AddressMapper viaFactory = Mappers.getMapper(AddressMapper.class);
t.section("1. Mappers.getMapper(...)");
t.line("AddressMapper (no dependencies) : %s", viaFactory.getClass().getSimpleName());
Throwable thrown = null;
try {
Mappers.getMapper(CustomerMapper.class);
} catch (RuntimeException e) {
thrown = e;
}
t.line("CustomerMapper (uses = {...}) : %s", thrown == null ? "worked" : thrown.getClass().getSimpleName());
Throwable cause = thrown;
while (cause != null && cause.getCause() != null) {
cause = cause.getCause();
}
t.line(" root cause : %s", cause == null ? "-" : cause.getClass().getName() + ": " + cause.getMessage());
CustomerMapper byHand = new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl());
t.section("2. new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl())");
t.line("no Spring, no reflection : %s", byHand.toDto(Fixtures.customer(), Fixtures.clock()).fullName());
assertThat(viaFactory).isInstanceOf(AddressMapperImpl.class);
assertThat(thrown).isNotNull();
assertThat(cause).isInstanceOf(NoSuchMethodException.class);
assertThat(byHand.toDto(Fixtures.customer(), Fixtures.clock()).fullName()).isEqualTo("Asha Mehta");
}
}
// ---- a property-level strategy ------------------------------------------------------------------
/** One mutation per DTO component: change the entity, and exactly that component must change. */
private static Map<String, Mutation> mutations() {
Map<String, Mutation> m = new LinkedHashMap<>();
m.put("id", new Mutation(c -> c.setId(42L), CustomerDto::id));
m.put("fullName", new Mutation(c -> c.setLastName("Shah"), CustomerDto::fullName));
m.put("email", new Mutation(c -> c.setEmail("[email protected]"), CustomerDto::email));
m.put("birthDate", new Mutation(c -> c.setBirthDate(LocalDate.of(2000, 1, 1)), CustomerDto::birthDate));
m.put("age", new Mutation(c -> c.setBirthDate(LocalDate.of(2000, 1, 1)), CustomerDto::age));
m.put("nickname", new Mutation(c -> c.setNickname("ash"), CustomerDto::nickname));
m.put("address", new Mutation(c -> c.getAddress().setCity("Nagpur"), CustomerDto::address));
m.put("roles", new Mutation(c -> c.getRoles().add("ADMIN"), CustomerDto::roles));
m.put("attributes", new Mutation(c -> c.getAttributes().put("k", "v"), CustomerDto::attributes));
m.put("orders", new Mutation(c -> c.getOrders().get(0).getLines().get(0).setQuantity(9), CustomerDto::orders));
return m;
}
private record Mutation(Consumer<Customer> change, Function<CustomerDto, Object> read) { }
@Test
void everyDtoComponentIsCoveredByAMutationTest() {
try (var t = new Transcript("14-property-level-test-strategy.txt",
"Property-level mapper test: change one entity property, expect exactly the right DTO component to change")) {
var mapper = Fixtures.customerMapper();
Clock clock = Fixtures.clock();
CustomerDto base = mapper.toDto(Fixtures.customer(), clock);
List<String> failures = new ArrayList<>();
mutations().forEach((component, mutation) -> {
Customer changed = Fixtures.customer();
mutation.change().accept(changed);
CustomerDto after = mapper.toDto(changed, clock);
boolean moved = !mutation.read().apply(after).equals(mutation.read().apply(base));
t.line("%-11s changes when its source changes : %s", component, moved);
if (!moved) {
failures.add(component);
}
});
var components = new TreeSet<String>();
for (var rc : CustomerDto.class.getRecordComponents()) {
components.add(rc.getName());
}
components.remove("origin"); // a constant: there is no source property to change
t.blank();
t.line("record components : %s", components);
t.line("covered by a test : %s", new TreeSet<>(mutations().keySet()));
assertThat(failures).isEmpty();
assertThat(new TreeSet<>(mutations().keySet())).isEqualTo(components);
assertThat(base.origin()).isEqualTo("api");
}
}
@Test
void mapperIsUsableThroughItsInterfaceOnly() {
// The same assertion style a team would copy: build the expected record by hand.
CustomerDto dto = Fixtures.customerMapper().toDto(Fixtures.customer(), Fixtures.clock());
assertThat(dto.address()).isEqualTo(new AddressDto("12 Park Road", "Pune", "411001", "IN"));
assertThatThrownBy(() -> Mappers.getMapper(CustomerMapper.class)).isInstanceOf(RuntimeException.class);
}
}
@@ -0,0 +1,60 @@
package com.ankurm.mapstruct;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} and echoes it to the console.
* Every console block quoted in the article comes out of one of these files verbatim.
* Lines end in LF on every platform so the files diff cleanly.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringBuilder buffer = new StringBuilder();
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
buffer.append("# ").append(title).append('\n').append('\n');
}
public Transcript line(String format, Object... args) {
buffer.append(args.length == 0 ? format : String.format(format, args)).append('\n');
return this;
}
public Transcript blank() {
buffer.append('\n');
return this;
}
public Transcript section(String heading) {
buffer.append('\n').append("--- ").append(heading).append(" ---").append('\n');
return this;
}
/** Appends a block of text verbatim (a trailing newline is added if missing). */
public Transcript block(String text) {
buffer.append(text);
if (!text.endsWith("\n")) {
buffer.append('\n');
}
return this;
}
@Override
public void close() {
// Absolute paths of whoever ran the build are environment noise, not a finding.
String dir = System.getProperty("user.dir");
String text = buffer.toString().replace(dir.replace('\\', '/'), "<mapstruct>").replace(dir, "<mapstruct>");
try {
Files.createDirectories(path.getParent());
Files.writeString(path, text, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(text);
}
}
@@ -0,0 +1,79 @@
package com.ankurm.mapstruct;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
import com.ankurm.mapstruct.web.CustomerController;
import com.ankurm.mapstruct.web.CustomerRepository;
import java.time.Clock;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import tools.jackson.databind.json.JsonMapper;
/** Records over HTTP on Boot 4: Jackson 3 reads and writes them with no extra configuration. */
@WebMvcTest(CustomerController.class)
@Import({CustomerMapperImpl.class, AddressMapperImpl.class, OrderMapperImpl.class, CustomerRepository.class,
WebJsonTests.PinnedClock.class})
class WebJsonTests {
@TestConfiguration
static class PinnedClock {
@Bean
Clock clock() {
return Fixtures.clock();
}
}
@Autowired
MockMvc mvc;
@Autowired
JsonMapper jsonMapper;
@Test
void recordsRoundTripThroughJackson3AndTheMapper() throws Exception {
try (var t = new Transcript("15-spring-boot-4-records-over-http.txt",
"MockMvc against CustomerController: records in, records out, Jackson 3 in the middle")) {
t.line("JSON library bean : %s", jsonMapper.getClass().getName());
String get = mvc.perform(get("/customers/1")).andReturn().getResponse().getContentAsString();
t.section("GET /customers/1");
t.line(get);
String created = mvc.perform(post("/customers").contentType(MediaType.APPLICATION_JSON).content("""
{"firstName":"Ravi","lastName":"Iyer","email":" [email protected] ","birthDate":"1985-01-02",
"address":{"street":"1 Lake View","city":"Chennai","zip":"600001"},"roles":["ADMIN"]}"""))
.andReturn().getResponse().getContentAsString();
t.section("POST /customers (no nickname, no tier, no country)");
t.line(created);
String patched = mvc.perform(patch("/customers/1").contentType(MediaType.APPLICATION_JSON)
.content("{\"nickname\":\"ash\"}")).andReturn().getResponse().getContentAsString();
t.section("PATCH /customers/1 {\"nickname\":\"ash\"} (every other component of the patch record is null)");
t.line(patched);
int missing = mvc.perform(get("/customers/99")).andReturn().getResponse().getStatus();
t.section("GET /customers/99");
t.line("status %d", missing);
assertThat(jsonMapper.getClass().getName()).startsWith("tools.jackson.");
assertThat(get).contains("\"fullName\":\"Asha Mehta\"").contains("\"age\":36")
.contains("\"birthDate\":\"1990-06-15\"").contains("\"nickname\":\"anonymous\"")
.contains("\"zip\":\"411001\"").doesNotContain("passwordHash");
assertThat(created).contains("\"email\":\"[email protected]\"").contains("\"fullName\":\"Ravi Iyer\"");
assertThat(patched).contains("\"nickname\":\"ash\"").contains("\"email\":\"[email protected]\"");
assertThat(missing).isEqualTo(404);
}
}
}
@@ -0,0 +1,23 @@
package com.ankurm.mapstruct.probe;
import java.util.IdentityHashMap;
import java.util.Map;
import org.mapstruct.BeforeMapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.TargetType;
/** The standard recipe: remember every source object already mapped and reuse its target. */
public class CycleContext {
private final Map<Object, Object> known = new IdentityHashMap<>();
@BeforeMapping
public <T> T lookup(Object source, @TargetType Class<T> targetType) {
return targetType.cast(known.get(source));
}
@BeforeMapping
public void remember(Object source, @MappingTarget Object target) {
known.put(source, target);
}
}
@@ -0,0 +1,15 @@
package com.ankurm.mapstruct.probe;
import com.ankurm.mapstruct.probe.Probes.Employee;
import com.ankurm.mapstruct.probe.Probes.EmployeeBean;
import com.ankurm.mapstruct.probe.Probes.EmployeeRecord;
import org.mapstruct.Context;
import org.mapstruct.Mapper;
@Mapper
public interface CycleMapper {
EmployeeRecord toRecord(Employee employee, @Context CycleContext context);
EmployeeBean toBean(Employee employee, @Context CycleContext context);
}
@@ -0,0 +1,18 @@
package com.ankurm.mapstruct.probe;
import com.ankurm.mapstruct.probe.Probes.Boxed;
import com.ankurm.mapstruct.probe.Probes.Pair;
import org.mapstruct.Mapper;
import org.mapstruct.ObjectFactory;
import org.mapstruct.ReportingPolicy;
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface FactoryTrapMapper {
Boxed toRecord(Pair pair);
@ObjectFactory
default Boxed create(Pair pair) {
return new Boxed("from-the-factory", 0);
}
}
@@ -0,0 +1,64 @@
package com.ankurm.mapstruct.probe;
import com.ankurm.mapstruct.domain.Customer;
import com.ankurm.mapstruct.domain.Order;
import com.ankurm.mapstruct.domain.OrderLine;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
/** The mapper you would write by hand: what MapStruct's output is being compared with. */
public final class HandWritten {
private HandWritten() { }
public static Views.CustomerView toView(Customer c) {
if (c == null) {
return null;
}
Views.CustomerView v = new Views.CustomerView();
v.setId(c.getId());
v.setFirstName(c.getFirstName());
v.setLastName(c.getLastName());
v.setEmail(c.getEmail());
v.setNickname(c.getNickname());
v.setTier(c.getTier());
if (c.getAddress() != null) {
Views.AddressView a = new Views.AddressView();
a.setStreet(c.getAddress().getStreet());
a.setCity(c.getAddress().getCity());
a.setPostalCode(c.getAddress().getPostalCode());
a.setCountry(c.getAddress().getCountry());
v.setAddress(a);
}
if (c.getRoles() != null) {
v.setRoles(new LinkedHashSet<>(c.getRoles()));
}
if (c.getAttributes() != null) {
v.setAttributes(new LinkedHashMap<>(c.getAttributes()));
}
if (c.getOrders() != null) {
List<Views.OrderView> orders = new ArrayList<>(c.getOrders().size());
for (Order o : c.getOrders()) {
Views.OrderView ov = new Views.OrderView();
ov.setId(o.getId());
ov.setStatus(o.getStatus() == null ? null : o.getStatus().name());
if (o.getLines() != null) {
List<Views.LineView> lines = new ArrayList<>(o.getLines().size());
for (OrderLine l : o.getLines()) {
Views.LineView lv = new Views.LineView();
lv.setSku(l.getSku());
lv.setQuantity(l.getQuantity());
lv.setUnitPrice(l.getUnitPrice());
lines.add(lv);
}
ov.setLines(lines);
}
orders.add(ov);
}
v.setOrders(orders);
}
return v;
}
}
@@ -0,0 +1,49 @@
package com.ankurm.mapstruct.probe;
import static com.ankurm.mapstruct.probe.Probes.TRACE;
import com.ankurm.mapstruct.probe.Probes.Boxed;
import com.ankurm.mapstruct.probe.Probes.BoxedBean;
import com.ankurm.mapstruct.probe.Probes.Pair;
import org.mapstruct.AfterMapping;
import org.mapstruct.BeforeMapping;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
@Mapper
public interface HookOrderMapper {
Boxed toRecord(Pair pair);
BoxedBean toBean(Pair pair);
@BeforeMapping
default void beforeSource(Pair pair) {
TRACE.add("@BeforeMapping(source)");
}
@BeforeMapping
default void beforeRecord(Pair pair, @MappingTarget Boxed target) {
TRACE.add("@BeforeMapping(source, @MappingTarget Boxed) target=" + target);
}
@AfterMapping
default void afterRecord(Pair pair, @MappingTarget Boxed target) {
TRACE.add("@AfterMapping(source, @MappingTarget Boxed) target=" + target);
}
@BeforeMapping
default void beforeBean(Pair pair, @MappingTarget BoxedBean target) {
TRACE.add("@BeforeMapping(source, @MappingTarget BoxedBean) name=" + target.getName());
}
@AfterMapping
default void afterBean(Pair pair, @MappingTarget BoxedBean target) {
TRACE.add("@AfterMapping(source, @MappingTarget BoxedBean) name=" + target.getName());
}
@AfterMapping
default void afterSource(Pair pair) {
TRACE.add("@AfterMapping(source)");
}
}
@@ -0,0 +1,46 @@
package com.ankurm.mapstruct.probe;
import java.util.ArrayList;
import java.util.List;
/**
* Small mappers in this package exist only to answer "what does MapStruct do when...?" questions.
* They use MapStruct's default component model (plain classes, no Spring) and live in test sources.
*/
public final class Probes {
private Probes() { }
/** Everything the hooks do is appended here, in call order. */
public static final List<String> TRACE = new ArrayList<>();
public record Pair(String name, int qty) { }
public record Boxed(String name, int qty) { }
public static class BoxedBean {
private String name;
private int qty;
public String getName() { return name; }
public void setName(String name) { TRACE.add(" BoxedBean.setName(" + name + ")"); this.name = name; }
public int getQty() { return qty; }
public void setQty(int qty) { TRACE.add(" BoxedBean.setQty(" + qty + ")"); this.qty = qty; }
}
public static class Employee {
public String name;
public Employee manager;
public Employee(String name) { this.name = name; }
}
public record EmployeeRecord(String name, EmployeeRecord manager) { }
public static class EmployeeBean {
private String name;
private EmployeeBean manager;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public EmployeeBean getManager() { return manager; }
public void setManager(EmployeeBean manager) { this.manager = manager; }
}
}
@@ -0,0 +1,10 @@
package com.ankurm.mapstruct.probe;
import com.ankurm.mapstruct.domain.Customer;
import org.mapstruct.Mapper;
/** Same-named properties, so MapStruct needs no @Mapping at all. */
@Mapper
public interface ViewMapper {
Views.CustomerView toView(Customer customer);
}
@@ -0,0 +1,70 @@
package com.ankurm.mapstruct.probe;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Mutable, setter-based copies of the DTO shape. The comparison uses setter-based beans because Dozer is a bean mapper. */
public final class Views {
private Views() { }
public static class AddressView {
private String street; private String city; private String postalCode; private String country;
public String getStreet() { return street; }
public void setStreet(String v) { street = v; }
public String getCity() { return city; }
public void setCity(String v) { city = v; }
public String getPostalCode() { return postalCode; }
public void setPostalCode(String v) { postalCode = v; }
public String getCountry() { return country; }
public void setCountry(String v) { country = v; }
}
public static class LineView {
private String sku; private int quantity; private BigDecimal unitPrice;
public String getSku() { return sku; }
public void setSku(String v) { sku = v; }
public int getQuantity() { return quantity; }
public void setQuantity(int v) { quantity = v; }
public BigDecimal getUnitPrice() { return unitPrice; }
public void setUnitPrice(BigDecimal v) { unitPrice = v; }
}
public static class OrderView {
private Long id; private String status; private List<LineView> lines;
public Long getId() { return id; }
public void setId(Long v) { id = v; }
public String getStatus() { return status; }
public void setStatus(String v) { status = v; }
public List<LineView> getLines() { return lines; }
public void setLines(List<LineView> v) { lines = v; }
}
public static class CustomerView {
private Long id; private String firstName; private String lastName; private String email;
private String nickname; private String tier; private AddressView address;
private Set<String> roles; private Map<String, String> attributes; private List<OrderView> orders;
public Long getId() { return id; }
public void setId(Long v) { id = v; }
public String getFirstName() { return firstName; }
public void setFirstName(String v) { firstName = v; }
public String getLastName() { return lastName; }
public void setLastName(String v) { lastName = v; }
public String getEmail() { return email; }
public void setEmail(String v) { email = v; }
public String getNickname() { return nickname; }
public void setNickname(String v) { nickname = v; }
public String getTier() { return tier; }
public void setTier(String v) { tier = v; }
public AddressView getAddress() { return address; }
public void setAddress(AddressView v) { address = v; }
public Set<String> getRoles() { return roles; }
public void setRoles(Set<String> v) { roles = v; }
public Map<String, String> getAttributes() { return attributes; }
public void setAttributes(Map<String, String> v) { attributes = v; }
public List<OrderView> getOrders() { return orders; }
public void setOrders(List<OrderView> v) { orders = v; }
}
}