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
+2
View File
@@ -54,6 +54,8 @@ files.
| [`boot42-rc1-checklist/`](boot42-rc1-checklist) | [Spring Framework 7.1 and Spring Boot 4.2: What's Coming and How to Prepare](https://ankurm.com/spring-boot-4-2-spring-framework-7-1-upgrade-checklist/) | each 4.2 milestone change run on 4.2.0-RC1 and on 4.1.2: `@DefaultValue` placeholders resolved, five new Kafka admin keys that 4.1.2 silently ignores, embedded LDAP SSL failing without a bundle, the properties migrator's real report (and the removed key it misses), and the `RestTemplate` removal warnings from the compiler |
| [`boot42-otel/`](boot42-otel) | [Spring Boot 4.2 OpenTelemetry: Semantic Conventions, Shared OTLP Settings and MDC Keys](https://ankurm.com/spring-boot-4-2-opentelemetry-semantic-conventions-shared-otlp-mdc/) | one request measured under each `management.observations.conventions` value (the documented `open-telemetry` fails to start on RC1, `opentelemetry` works), meter, span and Prometheus names before and after, `management.opentelemetry.otlp.*` verified against a local OTLP receiver (paths, merged headers, gzip, per-signal overrides), custom MDC keys, and the LongTaskTimer that is no longer on by default |
| [`boot4-migration/`](boot4-migration) | [Spring Boot 3 to 4 Migration Guide (Updated for 4.1 and 4.2)](https://ankurm.com/spring-boot-3-to-4-migration-guide/) | one Boot 3.5.16 service migrated to 4.1.2 with the real failures captured at each step: 12 javac errors from a version bump, a start-up failure from a missing `spring-boot-starter-restclient`, Flyway silently not running without `spring-boot-starter-flyway`, removed `logging.file.*` keys silently ignored, Jackson 3 sorting bean properties, liveness/readiness groups appearing, and a `@Mock` field that is `null` without `MockitoExtension` |
| [`startup-runners/`](startup-runners) | [CommandLineRunner vs ApplicationRunner vs ApplicationReadyEvent in Spring Boot 4](https://ankurm.com/commandlinerunner-vs-applicationrunner-vs-applicationreadyevent-spring-boot-4/) | the real order of every startup hook printed from a running app, one shared sort for both runner types with arbitrary ties, runner failure vs `ApplicationReadyEvent` listener failure (exit codes, `ApplicationFailedEvent`), a blocking runner holding readiness at 503, `SmartLifecycle` phases, and which hooks `@SpringBootTest` and `@WebMvcTest` run |
| [`mapstruct/`](mapstruct) | [MapStruct with Spring Boot 4 and Java Records: DTO Mapping Done Right](https://ankurm.com/mapstruct-spring-boot-4-java-records-dto-mapping/) | records as source and target, nested and collection mapping, null strategies, `@AfterMapping` and `@DecoratedWith`, real compile-time error text for broken mappers, three ways to test a mapper, and an indicative comparison with hand-written code and Dozer |
`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin`, `testing-no-containers`, `rest-test-client` and `hexagonal` are the exception: they have **no `docs/NN-topic.md` chapter folder**. Their deeper material lives in
collapsible sections inside the articles themselves. Where their captured output sits is not
+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; }
}
}
+68
View File
@@ -0,0 +1,68 @@
# startup-runners
Companion project for the article **[CommandLineRunner vs ApplicationRunner vs ApplicationReadyEvent in Spring Boot 4](https://ankurm.com/commandlinerunner-vs-applicationrunner-vs-applicationreadyevent-spring-boot-4/)** on **[ankurm.com](https://ankurm.com)**.
Every console block quoted in the article came out of `output/`. Transcripts 00-14 are written by the test
suite (so a claim that stops being true turns the build red); 15-17 are read out of the Spring Boot and Spring
Framework sources and jars by `scripts/capture-facts.sh`.
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible sections inside the article.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.2 (GA 2026-10-22 on Maven Central) |
| Spring Framework | 7.0.10 |
| JDK | 25 (25.0.3) |
| Maven | 3.9 |
| Sources read for contrast | Spring Boot 3.0.13 and 3.1.12 (`callRunners` only, not run) |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn test # runs the scenarios and rewrites output/00-14
./scripts/run-all.sh # everything, including 15-17 (needs network access to Maven Central once)
```
Transcript 03 (four runners without `@Order`) is the one file that is allowed to differ between runs: that is its point.
## Source layout
Each scenario is its own package with its own `@SpringBootApplication`.
| Package | What it holds |
|---|---|
| `hello/` | the smallest example: a `CommandLineRunner`, an `ApplicationRunner`, an `ApplicationReadyEvent` listener |
| `order/` | every startup hook plus `@PostConstruct`, `InitializingBean`, `SmartLifecycle`, printing the sequence and the readiness state |
| `ordering/`, `ties/` | `@Order` on classes, `@Bean` methods and `Ordered`; four runners with no order at all |
| `args/` | the same command line seen as `String[]` and as `ApplicationArguments` |
| `failure/` | a runner that throws, an `ApplicationReadyEvent` listener that throws, `ExitCodeGenerator`, a checked exception (profiles) |
| `blocking/` | a runner that blocks (web app, actuator probes) and one that hands its work to a virtual thread |
| `lifecycle/`, `lifestartfails/`, `lifeforgetful/` | `SmartLifecycle` phases, a `start()` that throws, an `isRunning()` that never says true |
| `slices/` | startup hooks declared four ways, for the `@SpringBootTest` and `@WebMvcTest` scenarios |
| `support/` | `Trace`, the in-memory log the tests print |
## Captured output
| File | What it shows |
|---|---|
| `00-hello.txt` | the three hooks of `HelloApp`, started with a real command line |
| `01-startup-hooks-in-order.txt` | every hook, in order, non-web and web, with the readiness state each one sees |
| `02-order-annotation-and-ordered.txt` | both runner kinds sorted together by `@Order` / `Ordered`; a bean that is both kinds |
| `03-ties-have-no-promised-order.txt` | four unordered runners in 8 fresh JVMs |
| `04-arguments.txt` | `String[]` versus `ApplicationArguments` |
| `05-runner-failure.txt` | a throwing runner: events, log, exit codes (1, 42), checked-exception wrapping |
| `06-ready-listener-failure.txt` | a throwing `ApplicationReadyEvent` listener, non-web and web |
| `07-blocking-runner-readiness.txt` | a blocking runner: port open, liveness UP, readiness 503 |
| `08-async-runner.txt` | a runner that returns at once and a background task that fails |
| `09-smartlifecycle-phases.txt` | start in ascending phase, stop in descending phase, async stop, a bean that is not auto-started |
| `10-lifecycle-beans-in-a-web-app.txt` | every `SmartLifecycle` bean in a web app with its phase |
| `11-smartlifecycle-start-fails.txt` | `start()` throws: no started event, no runner |
| `12-smartlifecycle-forgot-isrunning.txt` | `isRunning()` always false: `stop()` never called |
| `13-spring-boot-test-runs-the-hooks.txt` | what `@SpringBootTest` runs |
| `14-webmvctest-slice-hooks.txt` | what `@WebMvcTest` runs |
| `15-callrunners-source.txt` | `SpringApplication.run`, `callRunners`, `callRunner` and the run listener, from the 4.1.2 sources, plus 3.0.13 and 3.1.12 |
| `16-javap-callrunners.txt` | the compiled `callRunners` and the runner interfaces, read with `javap` |
| `17-lifecycleprocessor-source.txt` | when `DefaultLifecycleProcessor` calls `start()` and `stop()` |
+6
View File
@@ -0,0 +1,6 @@
# HelloApp started with: --greeting=hi --verbose notes.txt
hello: CommandLineRunner args = [--greeting=hi, --verbose, notes.txt]
hello: ApplicationRunner options = [greeting, verbose], non-option args = [notes.txt]
hello: ApplicationReadyEvent listener
process exit code: 0
@@ -0,0 +1,29 @@
# Every startup hook in the order Spring Boot 4.1.2 really calls it
--- non-web application ---
@PostConstruct
InitializingBean.afterPropertiesSet
SmartInitializingSingleton.afterSingletonsInstantiated
SmartLifecycle.start (default phase)
ContextRefreshedEvent
ApplicationStartedEvent
AvailabilityChangeEvent -> CORRECT
CommandLineRunner (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)
ApplicationRunner (readiness seen from inside: REFUSING_TRAFFIC)
ApplicationReadyEvent (readiness seen from inside: REFUSING_TRAFFIC)
AvailabilityChangeEvent -> ACCEPTING_TRAFFIC
--- servlet web application (Tomcat on a random port) ---
@PostConstruct
InitializingBean.afterPropertiesSet
SmartInitializingSingleton.afterSingletonsInstantiated
WebServerInitializedEvent (port is open)
SmartLifecycle.start (default phase)
ContextRefreshedEvent
ApplicationStartedEvent
AvailabilityChangeEvent -> CORRECT
CommandLineRunner (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)
ApplicationRunner (readiness seen from inside: REFUSING_TRAFFIC)
ApplicationReadyEvent (readiness seen from inside: REFUSING_TRAFFIC)
AvailabilityChangeEvent -> ACCEPTING_TRAFFIC
@@ -0,0 +1,12 @@
# CommandLineRunner and ApplicationRunner beans sorted together by @Order / Ordered
(declaration order in OrderingApp.java is NOT the order below)
CommandLineRunner @Order(HIGHEST_PRECEDENCE)
ApplicationRunner @Order(1) on the class
CommandLineRunner @Order(2) on the class
ApplicationRunner implements Ordered, getOrder() = 3
Both @Order(4) -> ApplicationRunner.run
Both @Order(4) -> CommandLineRunner.run
CommandLineRunner @Order(5) on a @Bean method
ApplicationRunner no order at all (lowest precedence)
@@ -0,0 +1,16 @@
# Four runners without @Order: the order chosen by each of 8 fresh JVMs
JVM 1: arD arB cliC cliA
JVM 2: cliC cliA arD arB
JVM 3: cliA arB arD cliC
JVM 4: arD arB cliC cliA
JVM 5: arD arB cliC cliA
JVM 6: cliC cliA arB arD
JVM 7: cliC cliA arB arD
JVM 8: arD arB cliC cliA
distinct sequences seen: 4
arD arB cliC cliA x4
cliC cliA arD arB x1
cliA arB arD cliC x1
cliC cliA arB arD x2
+14
View File
@@ -0,0 +1,14 @@
# The same command line, seen by CommandLineRunner and ApplicationRunner
args passed to SpringApplication.run: [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
CommandLineRunner String[] args = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
ApplicationRunner getSourceArgs() = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
ApplicationRunner getOptionNames() = [name, verbose]
ApplicationRunner getOptionValues(name) = [Ankur, Second]
ApplicationRunner getOptionValues(verbose) = []
ApplicationRunner getOptionValues(missing) = null
ApplicationRunner containsOption(verbose) = true
ApplicationRunner getNonOptionArgs() = [data.csv, -x, --after-the-dashes]
Environment property 'name' (both values, joined by a comma): Ankur,Second
@@ -0,0 +1,23 @@
# A runner throws: exit code, log, events, and the runners that never ran
--- three ordered runners, the second throws (non-web, in a separate JVM) ---
TRACE ApplicationStartedEvent
TRACE runner 1 ran
TRACE runner 2 is about to throw
TRACE ApplicationFailedEvent: java.lang.IllegalStateException: boom from runner 2
TRACE ContextClosedEvent (the context is shutting down)
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: boom from runner 2
process exit code: 1
--- a runner exception that implements ExitCodeGenerator ---
ERROR o.s.b.SpringApplication : Application run failed
com.ankurm.runners.failure.FailureApp$ExitWith42Exception: the data file is missing
process exit code: 42
--- a runner that throws a checked exception (declared with throws Exception) ---
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: Failed to execute CommandLineRunner
Caused by: java.io.IOException: cannot read data.csv
process exit code: 1
@@ -0,0 +1,20 @@
# An ApplicationReadyEvent listener throws: the app was 'ready' and is now gone
--- non-web application ---
TRACE ApplicationStartedEvent
TRACE ready listener 1 is about to throw (readiness: REFUSING_TRAFFIC)
TRACE ContextClosedEvent (the context is shutting down)
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: boom from the ready listener
process exit code: 1
--- web application (Tomcat is up when the listener runs) ---
TRACE ApplicationStartedEvent
TRACE GET /ping from inside the listener -> 200 pong
TRACE ready listener 1 is about to throw (readiness: REFUSING_TRAFFIC)
TRACE ContextClosedEvent (the context is shutting down)
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: boom from the ready listener
process exit code: 1
does the port answer after the process ended? false
@@ -0,0 +1,16 @@
# A runner that blocks: the port is open, liveness is UP, readiness is OUT_OF_SERVICE
--- while the runner is blocked ---
has SpringApplication.run() returned? false
GET /ping -> 200 pong
GET /actuator/health/liveness -> 200 {"status":"UP"}
GET /actuator/health/readiness -> 503 {"status":"OUT_OF_SERVICE"}
--- after the runner returned ---
has SpringApplication.run() returned? true
GET /actuator/health/readiness -> 200 {"status":"UP"}
--- trace ---
runner: started on platform thread app-main, now waiting (think: warming a cache for 40 s)
runner: finished, returning to SpringApplication.run
@@ -0,0 +1,14 @@
# A runner that starts a virtual thread and returns: readiness does not wait, and failures do not fail startup
--- the background work is still waiting ---
has SpringApplication.run() returned? true
GET /actuator/health/readiness -> 200 {"status":"UP"}
--- after the background work threw ---
GET /actuator/health/readiness -> 200 {"status":"UP"}
GET /ping -> 200 pong
--- trace ---
runner: started the work on a virtual thread and returned
background work on a virtual thread failed: the warm-up blew up
@@ -0,0 +1,22 @@
# SmartLifecycle: start in ascending phase, stop in descending phase, runners after all of it
--- startup ---
start low (phase -100) on main
start mid (phase 0) on main
start high (phase 100) on main
start slow (phase 200) on main
ApplicationStartedEvent
CommandLineRunner runs
ApplicationReadyEvent
manual.isAutoStartup()=false isRunning()=false (never started by the context)
--- context.close() ---
start manual (phase 50) on main
ContextClosedEvent (stop phase begins)
stop slow (phase 200) begins, will finish in 300 ms
stop slow finished, calling the callback
stop high (phase 100) on main
stop manual (phase 50) on main
stop mid (phase 0) on main
stop low (phase -100) on main
@@ -0,0 +1,15 @@
# Every SmartLifecycle bean in a servlet web application, by phase (the ones this project adds are marked)
phase -2147483647 springBootLoggingLifecycle Lifecycle
phase -100 low (this project)
phase 0 mid (this project)
phase 50 manual (this project)
phase 100 high (this project)
phase 200 slow (this project)
phase 1073741823 applicationTaskExecutor ThreadPoolTaskExecutor
phase 2147481599 webServerStartStop WebServerStartStopLifecycle
phase 2147482623 webServerGracefulShutdown WebServerGracefulShutdownLifecycle
SmartLifecycle.DEFAULT_PHASE = 2147483647
Integer.MAX_VALUE = 2147483647
timeout per shutdown phase = 30000 ms (spring.lifecycle.timeout-per-shutdown-phase, set by Boot)
Spring Boot 4.1.2, Spring Framework 7.0.10
@@ -0,0 +1,9 @@
# SmartLifecycle.start() throws: no ApplicationStartedEvent, no runner
start healthy (phase 0)
start broken (phase 10)
stop healthy
application started? false
org.springframework.context.ApplicationContextException: Failed to start bean 'broken'
java.lang.IllegalStateException: could not connect to the broker
@@ -0,0 +1,6 @@
# isRunning() always false: start() is called, stop() never is
--- startup ---
start() was called
--- after context.close() ---
(nothing)
@@ -0,0 +1,6 @@
# @SpringBootTest(classes = SlicesApp.class, args = "--from-the-test=1"): what ran before the first test method
CommandLineRunner from a top-level @Component ran, args = [--from-the-test=1]
CommandLineRunner nested in SlicesApp ran, args = [--from-the-test=1]
ApplicationRunner from a @Bean method ran, source args = [--from-the-test=1]
ApplicationReadyEvent listener (a top-level @Component) ran
@@ -0,0 +1,4 @@
# @WebMvcTest(controllers = Hello.class): what ran before the first test method
CommandLineRunner nested in SlicesApp ran, args = []
ApplicationRunner from a @Bean method ran, source args = []
@@ -0,0 +1,81 @@
# Spring Boot 4.1.2 sources: the order SpringApplication.run() calls things in, and callRunners
--- SpringApplication.run(): from refresh to ready (lines containing the calls) ---
18: refreshContext(context);
19: afterRefresh(context, applicationArguments);
24: listeners.started(context, timeTakenToStarted);
25: callRunners(context, applicationArguments);
28: throw handleRunFailure(context, ex, listeners);
32: listeners.ready(context, startup.ready());
36: throw handleRunFailure(context, ex, null);
--- SpringApplication.callRunners ---
private void callRunners(ConfigurableApplicationContext context, ApplicationArguments args) {
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
String[] beanNames = beanFactory.getBeanNamesForType(Runner.class);
Map<Runner, String> instancesToBeanNames = new IdentityHashMap<>();
for (String beanName : beanNames) {
instancesToBeanNames.put(beanFactory.getBean(beanName, Runner.class), beanName);
}
Comparator<Object> comparator = getOrderComparator(beanFactory)
.withSourceProvider(new FactoryAwareOrderSourceProvider(beanFactory, instancesToBeanNames));
instancesToBeanNames.keySet().stream().sorted(comparator).forEach((runner) -> callRunner(runner, args));
}
--- SpringApplication.callRunner (what happens to a bean that is both kinds) ---
private void callRunner(Runner runner, ApplicationArguments args) {
if (runner instanceof ApplicationRunner) {
callRunner(ApplicationRunner.class, runner, (applicationRunner) -> applicationRunner.run(args));
}
if (runner instanceof CommandLineRunner) {
callRunner(CommandLineRunner.class, runner,
(commandLineRunner) -> commandLineRunner.run(args.getSourceArgs()));
}
}
--- SpringApplication.callRunner(Class, Runner, ThrowingConsumer) (where 'Failed to execute' comes from) ---
private <R extends Runner> void callRunner(Class<R> type, Runner runner, ThrowingConsumer<R> call) {
call.throwing(
(message, ex) -> new IllegalStateException("Failed to execute " + ClassUtils.getShortName(type), ex))
.accept((R) runner);
}
--- EventPublishingRunListener.started / ready ---
public void started(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
context.publishEvent(new ApplicationStartedEvent(this.application, this.args, context, timeTaken));
AvailabilityChangeEvent.publish(context, LivenessState.CORRECT);
}
public void ready(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
context.publishEvent(new ApplicationReadyEvent(this.application, this.args, context, timeTaken));
AvailabilityChangeEvent.publish(context, ReadinessState.ACCEPTING_TRAFFIC);
}
--- the marker interface that both runners extend ---
interface Runner {
public interface CommandLineRunner extends Runner {
public interface ApplicationRunner extends Runner {
--- the same method in Spring Boot 3.0.13 ---
private void callRunners(ApplicationContext context, ApplicationArguments args) {
context.getBeanProvider(Runner.class).orderedStream().forEach((runner) -> {
if (runner instanceof ApplicationRunner applicationRunner) {
callRunner(applicationRunner, args);
}
if (runner instanceof CommandLineRunner commandLineRunner) {
callRunner(commandLineRunner, args);
}
});
}
--- the same method in Spring Boot 3.1.12 ---
private void callRunners(ConfigurableApplicationContext context, ApplicationArguments args) {
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
String[] beanNames = beanFactory.getBeanNamesForType(Runner.class);
Map<Runner, String> instancesToBeanNames = new IdentityHashMap<>();
for (String beanName : beanNames) {
instancesToBeanNames.put(beanFactory.getBean(beanName, Runner.class), beanName);
}
Comparator<Object> comparator = getOrderComparator(beanFactory)
.withSourceProvider(new FactoryAwareOrderSourceProvider(beanFactory, instancesToBeanNames));
instancesToBeanNames.keySet().stream().sorted(comparator).forEach((runner) -> callRunner(runner, args));
}

Some files were not shown because too many files have changed in this diff Show More