From dc13293046044b074c7b639028d17964954dee31 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Fri, 6 Nov 2026 01:05:27 +0530 Subject: [PATCH] New posts for Spring and Hibernate --- README.md | 2 + mapstruct/README.md | 78 +++++++++ .../01-records-as-source-and-target.txt | 14 ++ .../output/02-generated-implementation.txt | 127 ++++++++++++++ mapstruct/output/03-entity-to-record.txt | 17 ++ mapstruct/output/04-record-to-entity.txt | 13 ++ mapstruct/output/05-null-strategies.txt | 50 ++++++ mapstruct/output/06-update-in-place.txt | 11 ++ mapstruct/output/07-hooks-on-records.txt | 40 +++++ mapstruct/output/08-object-factory-trap.txt | 16 ++ mapstruct/output/09-builder-and-decorator.txt | 31 ++++ mapstruct/output/10-context-and-cycles.txt | 28 +++ mapstruct/output/11-testing-mappers-plain.txt | 10 ++ ...2-testing-mappers-contextconfiguration.txt | 6 + .../13-testing-mappers-springboottest.txt | 6 + .../14-property-level-test-strategy.txt | 15 ++ .../15-spring-boot-4-records-over-http.txt | 15 ++ ...cative-timing-vs-handwritten-and-dozer.txt | 13 ++ mapstruct/output/17-error-unmapped-target.txt | 7 + .../18-error-ambiguous-mapping-methods.txt | 7 + .../19-error-missing-source-property.txt | 7 + ...method-on-a-record-compiles-to-a-no-op.txt | 15 ++ mapstruct/output/21-error-cannot-map-type.txt | 7 + .../output/22-error-decorator-constructor.txt | 10 ++ .../23-processor-on-the-plain-classpath.txt | 24 +++ .../output/24-lombok-processor-order.txt | 25 +++ mapstruct/output/25-processor-jar-facts.txt | 20 +++ mapstruct/pom.xml | 80 +++++++++ mapstruct/scripts/capture-facts.sh | 22 +++ mapstruct/scripts/compile-errors.sh | 130 ++++++++++++++ mapstruct/scripts/run-all.sh | 24 +++ .../src/broken/ambiguous/BrokenMapper.java | 22 +++ .../src/broken/cannot-map/BrokenMapper.java | 18 ++ .../decorator-constructor/BrokenMapper.java | 14 ++ .../BrokenMapperDecorator.java | 13 ++ mapstruct/src/broken/lombok/LombokMapper.java | 20 +++ .../broken/missing-source/BrokenMapper.java | 14 ++ .../broken/processor-not-run/UsesMapper.java | 17 ++ .../broken/unmapped-target/BrokenMapper.java | 13 ++ .../broken/update-record/BrokenMapper.java | 13 ++ .../com/ankurm/mapstruct/ClockConfig.java | 15 ++ .../mapstruct/MapstructApplication.java | 12 ++ .../com/ankurm/mapstruct/domain/Address.java | 24 +++ .../com/ankurm/mapstruct/domain/Customer.java | 56 ++++++ .../com/ankurm/mapstruct/domain/Order.java | 26 +++ .../ankurm/mapstruct/domain/OrderLine.java | 22 +++ .../ankurm/mapstruct/domain/OrderStatus.java | 3 + .../com/ankurm/mapstruct/dto/AddressDto.java | 4 + .../com/ankurm/mapstruct/dto/CustomerDto.java | 20 +++ .../ankurm/mapstruct/dto/CustomerPatch.java | 6 + .../ankurm/mapstruct/dto/CustomerRequest.java | 15 ++ .../com/ankurm/mapstruct/dto/InvoiceDto.java | 5 + .../com/ankurm/mapstruct/dto/OrderDto.java | 7 + .../ankurm/mapstruct/dto/OrderLineDto.java | 5 + .../com/ankurm/mapstruct/dto/ReceiptDto.java | 25 +++ .../mapstruct/mapper/AddressMapper.java | 27 +++ .../mapstruct/mapper/CentralConfig.java | 17 ++ .../mapstruct/mapper/CustomerMapper.java | 82 +++++++++ .../mapstruct/mapper/InvoiceMapper.java | 17 ++ .../mapper/InvoiceMapperDecorator.java | 32 ++++ .../mapstruct/mapper/LenientOrderMapper.java | 21 +++ .../ankurm/mapstruct/mapper/OrderMapper.java | 36 ++++ .../mapstruct/mapper/ReceiptMapper.java | 24 +++ .../mapstruct/web/CustomerController.java | 51 ++++++ .../mapstruct/web/CustomerRepository.java | 60 +++++++ .../com/ankurm/mapstruct/BenchmarkTests.java | 93 ++++++++++ .../ContextConfigurationMapperTest.java | 55 ++++++ .../java/com/ankurm/mapstruct/Fixtures.java | 70 ++++++++ .../java/com/ankurm/mapstruct/HooksTests.java | 159 +++++++++++++++++ .../ankurm/mapstruct/MappingBasicsTests.java | 162 +++++++++++++++++ .../mapstruct/NullHandlingAndUpdateTests.java | 121 +++++++++++++ .../mapstruct/SpringBootMapperTest.java | 41 +++++ .../mapstruct/TestingStrategiesTests.java | 124 +++++++++++++ .../java/com/ankurm/mapstruct/Transcript.java | 60 +++++++ .../com/ankurm/mapstruct/WebJsonTests.java | 79 +++++++++ .../ankurm/mapstruct/probe/CycleContext.java | 23 +++ .../ankurm/mapstruct/probe/CycleMapper.java | 15 ++ .../mapstruct/probe/FactoryTrapMapper.java | 18 ++ .../ankurm/mapstruct/probe/HandWritten.java | 64 +++++++ .../mapstruct/probe/HookOrderMapper.java | 49 ++++++ .../com/ankurm/mapstruct/probe/Probes.java | 46 +++++ .../ankurm/mapstruct/probe/ViewMapper.java | 10 ++ .../com/ankurm/mapstruct/probe/Views.java | 70 ++++++++ startup-runners/README.md | 68 ++++++++ startup-runners/output/00-hello.txt | 6 + .../output/01-startup-hooks-in-order.txt | 29 ++++ .../02-order-annotation-and-ordered.txt | 12 ++ .../output/03-ties-have-no-promised-order.txt | 16 ++ startup-runners/output/04-arguments.txt | 14 ++ startup-runners/output/05-runner-failure.txt | 23 +++ .../output/06-ready-listener-failure.txt | 20 +++ .../output/07-blocking-runner-readiness.txt | 16 ++ startup-runners/output/08-async-runner.txt | 14 ++ .../output/09-smartlifecycle-phases.txt | 22 +++ .../10-lifecycle-beans-in-a-web-app.txt | 15 ++ .../output/11-smartlifecycle-start-fails.txt | 9 + .../12-smartlifecycle-forgot-isrunning.txt | 6 + .../13-spring-boot-test-runs-the-hooks.txt | 6 + .../output/14-webmvctest-slice-hooks.txt | 4 + .../output/15-callrunners-source.txt | 81 +++++++++ .../output/16-javap-callrunners.txt | 31 ++++ .../output/17-lifecycleprocessor-source.txt | 97 +++++++++++ startup-runners/pom.xml | 61 +++++++ startup-runners/scripts/capture-facts.sh | 96 ++++++++++ startup-runners/scripts/run-all.sh | 21 +++ .../java/com/ankurm/runners/args/ArgsApp.java | 44 +++++ .../ankurm/runners/blocking/BlockingApp.java | 65 +++++++ .../com/ankurm/runners/blocking/Gate.java | 20 +++ .../ankurm/runners/failure/FailureApp.java | 164 ++++++++++++++++++ .../com/ankurm/runners/hello/HelloApp.java | 38 ++++ .../runners/lifecycle/LifecycleApp.java | 72 ++++++++ .../com/ankurm/runners/lifecycle/Svc.java | 72 ++++++++ .../runners/lifeforgetful/ForgetfulApp.java | 37 ++++ .../runners/lifestartfails/StartFailsApp.java | 88 ++++++++++ .../com/ankurm/runners/order/EventProbe.java | 47 +++++ .../ankurm/runners/order/LifecycleProbe.java | 49 ++++++ .../com/ankurm/runners/order/OrderApp.java | 13 ++ .../com/ankurm/runners/order/Runners.java | 46 +++++ .../ankurm/runners/ordering/OrderingApp.java | 92 ++++++++++ .../runners/slices/ComponentRunner.java | 16 ++ .../java/com/ankurm/runners/slices/Hello.java | 14 ++ .../ankurm/runners/slices/ReadyListener.java | 16 ++ .../com/ankurm/runners/slices/SlicesApp.java | 39 +++++ .../com/ankurm/runners/support/Trace.java | 43 +++++ .../java/com/ankurm/runners/ties/TiesApp.java | 40 +++++ .../com/ankurm/runners/BlockingTests.java | 114 ++++++++++++ .../test/java/com/ankurm/runners/BootRun.java | 56 ++++++ .../java/com/ankurm/runners/FailureTests.java | 86 +++++++++ .../test/java/com/ankurm/runners/Forked.java | 78 +++++++++ .../java/com/ankurm/runners/HelloTests.java | 25 +++ .../com/ankurm/runners/LifecycleTests.java | 97 +++++++++++ .../java/com/ankurm/runners/OrderTests.java | 46 +++++ .../java/com/ankurm/runners/RunnerTests.java | 79 +++++++++ .../runners/SpringBootTestHooksTests.java | 30 ++++ .../java/com/ankurm/runners/Transcript.java | 54 ++++++ .../com/ankurm/runners/WebMvcSliceTests.java | 36 ++++ 136 files changed, 5268 insertions(+) create mode 100644 mapstruct/README.md create mode 100644 mapstruct/output/01-records-as-source-and-target.txt create mode 100644 mapstruct/output/02-generated-implementation.txt create mode 100644 mapstruct/output/03-entity-to-record.txt create mode 100644 mapstruct/output/04-record-to-entity.txt create mode 100644 mapstruct/output/05-null-strategies.txt create mode 100644 mapstruct/output/06-update-in-place.txt create mode 100644 mapstruct/output/07-hooks-on-records.txt create mode 100644 mapstruct/output/08-object-factory-trap.txt create mode 100644 mapstruct/output/09-builder-and-decorator.txt create mode 100644 mapstruct/output/10-context-and-cycles.txt create mode 100644 mapstruct/output/11-testing-mappers-plain.txt create mode 100644 mapstruct/output/12-testing-mappers-contextconfiguration.txt create mode 100644 mapstruct/output/13-testing-mappers-springboottest.txt create mode 100644 mapstruct/output/14-property-level-test-strategy.txt create mode 100644 mapstruct/output/15-spring-boot-4-records-over-http.txt create mode 100644 mapstruct/output/16-indicative-timing-vs-handwritten-and-dozer.txt create mode 100644 mapstruct/output/17-error-unmapped-target.txt create mode 100644 mapstruct/output/18-error-ambiguous-mapping-methods.txt create mode 100644 mapstruct/output/19-error-missing-source-property.txt create mode 100644 mapstruct/output/20-update-method-on-a-record-compiles-to-a-no-op.txt create mode 100644 mapstruct/output/21-error-cannot-map-type.txt create mode 100644 mapstruct/output/22-error-decorator-constructor.txt create mode 100644 mapstruct/output/23-processor-on-the-plain-classpath.txt create mode 100644 mapstruct/output/24-lombok-processor-order.txt create mode 100644 mapstruct/output/25-processor-jar-facts.txt create mode 100644 mapstruct/pom.xml create mode 100644 mapstruct/scripts/capture-facts.sh create mode 100644 mapstruct/scripts/compile-errors.sh create mode 100644 mapstruct/scripts/run-all.sh create mode 100644 mapstruct/src/broken/ambiguous/BrokenMapper.java create mode 100644 mapstruct/src/broken/cannot-map/BrokenMapper.java create mode 100644 mapstruct/src/broken/decorator-constructor/BrokenMapper.java create mode 100644 mapstruct/src/broken/decorator-constructor/BrokenMapperDecorator.java create mode 100644 mapstruct/src/broken/lombok/LombokMapper.java create mode 100644 mapstruct/src/broken/missing-source/BrokenMapper.java create mode 100644 mapstruct/src/broken/processor-not-run/UsesMapper.java create mode 100644 mapstruct/src/broken/unmapped-target/BrokenMapper.java create mode 100644 mapstruct/src/broken/update-record/BrokenMapper.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/ClockConfig.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/MapstructApplication.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/domain/Address.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/domain/Customer.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/domain/Order.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/domain/OrderLine.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/domain/OrderStatus.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/dto/AddressDto.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerDto.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerPatch.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerRequest.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/dto/InvoiceDto.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/dto/OrderDto.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/dto/OrderLineDto.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/dto/ReceiptDto.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/mapper/AddressMapper.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/mapper/CentralConfig.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/mapper/CustomerMapper.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/mapper/InvoiceMapper.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/mapper/InvoiceMapperDecorator.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/mapper/LenientOrderMapper.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/mapper/OrderMapper.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/mapper/ReceiptMapper.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/web/CustomerController.java create mode 100644 mapstruct/src/main/java/com/ankurm/mapstruct/web/CustomerRepository.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/BenchmarkTests.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/ContextConfigurationMapperTest.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/Fixtures.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/HooksTests.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/MappingBasicsTests.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/NullHandlingAndUpdateTests.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/SpringBootMapperTest.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/TestingStrategiesTests.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/Transcript.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/WebJsonTests.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/probe/CycleContext.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/probe/CycleMapper.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/probe/FactoryTrapMapper.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/probe/HandWritten.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/probe/HookOrderMapper.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/probe/Probes.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/probe/ViewMapper.java create mode 100644 mapstruct/src/test/java/com/ankurm/mapstruct/probe/Views.java create mode 100644 startup-runners/README.md create mode 100644 startup-runners/output/00-hello.txt create mode 100644 startup-runners/output/01-startup-hooks-in-order.txt create mode 100644 startup-runners/output/02-order-annotation-and-ordered.txt create mode 100644 startup-runners/output/03-ties-have-no-promised-order.txt create mode 100644 startup-runners/output/04-arguments.txt create mode 100644 startup-runners/output/05-runner-failure.txt create mode 100644 startup-runners/output/06-ready-listener-failure.txt create mode 100644 startup-runners/output/07-blocking-runner-readiness.txt create mode 100644 startup-runners/output/08-async-runner.txt create mode 100644 startup-runners/output/09-smartlifecycle-phases.txt create mode 100644 startup-runners/output/10-lifecycle-beans-in-a-web-app.txt create mode 100644 startup-runners/output/11-smartlifecycle-start-fails.txt create mode 100644 startup-runners/output/12-smartlifecycle-forgot-isrunning.txt create mode 100644 startup-runners/output/13-spring-boot-test-runs-the-hooks.txt create mode 100644 startup-runners/output/14-webmvctest-slice-hooks.txt create mode 100644 startup-runners/output/15-callrunners-source.txt create mode 100644 startup-runners/output/16-javap-callrunners.txt create mode 100644 startup-runners/output/17-lifecycleprocessor-source.txt create mode 100644 startup-runners/pom.xml create mode 100644 startup-runners/scripts/capture-facts.sh create mode 100644 startup-runners/scripts/run-all.sh create mode 100644 startup-runners/src/main/java/com/ankurm/runners/args/ArgsApp.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/blocking/BlockingApp.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/blocking/Gate.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/failure/FailureApp.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/hello/HelloApp.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/lifecycle/LifecycleApp.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/lifecycle/Svc.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/lifeforgetful/ForgetfulApp.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/lifestartfails/StartFailsApp.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/order/EventProbe.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/order/LifecycleProbe.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/order/OrderApp.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/order/Runners.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/ordering/OrderingApp.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/slices/ComponentRunner.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/slices/Hello.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/slices/ReadyListener.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/slices/SlicesApp.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/support/Trace.java create mode 100644 startup-runners/src/main/java/com/ankurm/runners/ties/TiesApp.java create mode 100644 startup-runners/src/test/java/com/ankurm/runners/BlockingTests.java create mode 100644 startup-runners/src/test/java/com/ankurm/runners/BootRun.java create mode 100644 startup-runners/src/test/java/com/ankurm/runners/FailureTests.java create mode 100644 startup-runners/src/test/java/com/ankurm/runners/Forked.java create mode 100644 startup-runners/src/test/java/com/ankurm/runners/HelloTests.java create mode 100644 startup-runners/src/test/java/com/ankurm/runners/LifecycleTests.java create mode 100644 startup-runners/src/test/java/com/ankurm/runners/OrderTests.java create mode 100644 startup-runners/src/test/java/com/ankurm/runners/RunnerTests.java create mode 100644 startup-runners/src/test/java/com/ankurm/runners/SpringBootTestHooksTests.java create mode 100644 startup-runners/src/test/java/com/ankurm/runners/Transcript.java create mode 100644 startup-runners/src/test/java/com/ankurm/runners/WebMvcSliceTests.java diff --git a/README.md b/README.md index d60cf3d..f77d5ff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/mapstruct/README.md b/mapstruct/README.md new file mode 100644 index 0000000..17840d6 --- /dev/null +++ b/mapstruct/README.md @@ -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` | diff --git a/mapstruct/output/01-records-as-source-and-target.txt b/mapstruct/output/01-records-as-source-and-target.txt new file mode 100644 index 0000000..94fd550 --- /dev/null +++ b/mapstruct/output/01-records-as-source-and-target.txt @@ -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() ); diff --git a/mapstruct/output/02-generated-implementation.txt b/mapstruct/output/02-generated-implementation.txt new file mode 100644 index 0000000..c5efbae --- /dev/null +++ b/mapstruct/output/02-generated-implementation.txt @@ -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 roles = null; + Map attributes = null; + List 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 set = customer.getRoles(); + if ( set != null ) { + roles = new LinkedHashSet( set ); + } + Map map = customer.getAttributes(); + if ( map != null ) { + attributes = new LinkedHashMap( 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; + } diff --git a/mapstruct/output/03-entity-to-record.txt b/mapstruct/output/03-entity-to-record.txt new file mode 100644 index 0000000..ce94d2c --- /dev/null +++ b/mapstruct/output/03-entity-to-record.txt @@ -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 -> List, 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 diff --git a/mapstruct/output/04-record-to-entity.txt b/mapstruct/output/04-record-to-entity.txt new file mode 100644 index 0000000..ce007c3 --- /dev/null +++ b/mapstruct/output/04-record-to-entity.txt @@ -0,0 +1,13 @@ +# CustomerRequest (record) -> Customer (entity); AddressMapper.toEntity inherits the rename in reverse + +email after @AfterMapping : 'ravi.iyer@example.com' (request carried ' Ravi.Iyer@Example.COM ') +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 diff --git a/mapstruct/output/05-null-strategies.txt b/mapstruct/output/05-null-strategies.txt new file mode 100644 index 0000000..9862bb2 --- /dev/null +++ b/mapstruct/output/05-null-strategies.txt @@ -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=Asha.Mehta@Example.com, nickname=ash, roles=[USER], address=12 Park Road/Pune/411001 +after : email=Asha.Mehta@Example.com, nickname=ash, roles=[USER], address=12 Park Road/Pune/411001 + +--- replace(): the default strategy (SET_TO_NULL), same all-null patch --- +before: email=Asha.Mehta@Example.com, 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 set = patch.roles(); + if ( set != null ) { + customer.getRoles().clear(); + customer.getRoles().addAll( set ); + } + } + else { + Set set = patch.roles(); + if ( set != null ) { + customer.setRoles( new LinkedHashSet( set ) ); + } + } + } diff --git a/mapstruct/output/06-update-in-place.txt b/mapstruct/output/06-update-in-place.txt new file mode 100644 index 0000000..37b79ce --- /dev/null +++ b/mapstruct/output/06-update-in-place.txt @@ -0,0 +1,11 @@ +# @MappingTarget updates: nested objects and collections are changed in place + + +--- patch only the city and the roles --- +address: email=Asha.Mehta@Example.com, 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 diff --git a/mapstruct/output/07-hooks-on-records.txt b/mapstruct/output/07-hooks-on-records.txt new file mode 100644 index 0000000..556ecf7 --- /dev/null +++ b/mapstruct/output/07-hooks-on-records.txt @@ -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; + } diff --git a/mapstruct/output/08-object-factory-trap.txt b/mapstruct/output/08-object-factory-trap.txt new file mode 100644 index 0000000..3b66cd5 --- /dev/null +++ b/mapstruct/output/08-object-factory-trap.txt @@ -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; + } diff --git a/mapstruct/output/09-builder-and-decorator.txt b/mapstruct/output/09-builder-and-decorator.txt new file mode 100644 index 0000000..7e850f4 --- /dev/null +++ b/mapstruct/output/09-builder-and-decorator.txt @@ -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 { +} diff --git a/mapstruct/output/10-context-and-cycles.txt b/mapstruct/output/10-context-and-cycles.txt new file mode 100644 index 0000000..b695572 --- /dev/null +++ b/mapstruct/output/10-context-and-cycles.txt @@ -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; + } diff --git a/mapstruct/output/11-testing-mappers-plain.txt b/mapstruct/output/11-testing-mappers-plain.txt new file mode 100644 index 0000000..114a5fd --- /dev/null +++ b/mapstruct/output/11-testing-mappers-plain.txt @@ -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.() + +--- 2. new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl()) --- +no Spring, no reflection : Asha Mehta diff --git a/mapstruct/output/12-testing-mappers-contextconfiguration.txt b/mapstruct/output/12-testing-mappers-contextconfiguration.txt new file mode 100644 index 0000000..ff3d1b2 --- /dev/null +++ b/mapstruct/output/12-testing-mappers-contextconfiguration.txt @@ -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 diff --git a/mapstruct/output/13-testing-mappers-springboottest.txt b/mapstruct/output/13-testing-mappers-springboottest.txt new file mode 100644 index 0000000..fe1868d --- /dev/null +++ b/mapstruct/output/13-testing-mappers-springboottest.txt @@ -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 diff --git a/mapstruct/output/14-property-level-test-strategy.txt b/mapstruct/output/14-property-level-test-strategy.txt new file mode 100644 index 0000000..4a576f0 --- /dev/null +++ b/mapstruct/output/14-property-level-test-strategy.txt @@ -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] diff --git a/mapstruct/output/15-spring-boot-4-records-over-http.txt b/mapstruct/output/15-spring-boot-4-records-over-http.txt new file mode 100644 index 0000000..34dbaa8 --- /dev/null +++ b/mapstruct/output/15-spring-boot-4-records-over-http.txt @@ -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":"Asha.Mehta@Example.com","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":"ravi.iyer@example.com","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":"Asha.Mehta@Example.com","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 diff --git a/mapstruct/output/16-indicative-timing-vs-handwritten-and-dozer.txt b/mapstruct/output/16-indicative-timing-vs-handwritten-and-dozer.txt new file mode 100644 index 0000000..3c1cc86 --- /dev/null +++ b/mapstruct/output/16-indicative-timing-vs-handwritten-and-dozer.txt @@ -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. diff --git a/mapstruct/output/17-error-unmapped-target.txt b/mapstruct/output/17-error-unmapped-target.txt new file mode 100644 index 0000000..1fc2c6f --- /dev/null +++ b/mapstruct/output/17-error-unmapped-target.txt @@ -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 diff --git a/mapstruct/output/18-error-ambiguous-mapping-methods.txt b/mapstruct/output/18-error-ambiguous-mapping-methods.txt new file mode 100644 index 0000000..5e58ed3 --- /dev/null +++ b/mapstruct/output/18-error-ambiguous-mapping-methods.txt @@ -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 diff --git a/mapstruct/output/19-error-missing-source-property.txt b/mapstruct/output/19-error-missing-source-property.txt new file mode 100644 index 0000000..0ec8248 --- /dev/null +++ b/mapstruct/output/19-error-missing-source-property.txt @@ -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 diff --git a/mapstruct/output/20-update-method-on-a-record-compiles-to-a-no-op.txt b/mapstruct/output/20-update-method-on-a-record-compiles-to-a-no-op.txt new file mode 100644 index 0000000..e4d8757 --- /dev/null +++ b/mapstruct/output/20-update-method-on-a-record-compiles-to-a-no-op.txt @@ -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; + } + } +} diff --git a/mapstruct/output/21-error-cannot-map-type.txt b/mapstruct/output/21-error-cannot-map-type.txt new file mode 100644 index 0000000..61382a8 --- /dev/null +++ b/mapstruct/output/21-error-cannot-map-type.txt @@ -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 diff --git a/mapstruct/output/22-error-decorator-constructor.txt b/mapstruct/output/22-error-decorator-constructor.txt new file mode 100644 index 0000000..2b8bed2 --- /dev/null +++ b/mapstruct/output/22-error-decorator-constructor.txt @@ -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 diff --git a/mapstruct/output/23-processor-on-the-plain-classpath.txt b/mapstruct/output/23-processor-on-the-plain-classpath.txt new file mode 100644 index 0000000..1d57ae6 --- /dev/null +++ b/mapstruct/output/23-processor-on-the-plain-classpath.txt @@ -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 diff --git a/mapstruct/output/24-lombok-processor-order.txt b/mapstruct/output/24-lombok-processor-order.txt new file mode 100644 index 0000000..8198ded --- /dev/null +++ b/mapstruct/output/24-lombok-processor-order.txt @@ -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 diff --git a/mapstruct/output/25-processor-jar-facts.txt b/mapstruct/output/25-processor-jar-facts.txt new file mode 100644 index 0000000..a43df77 --- /dev/null +++ b/mapstruct/output/25-processor-jar-facts.txt @@ -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 diff --git a/mapstruct/pom.xml b/mapstruct/pom.xml new file mode 100644 index 0000000..495e91a --- /dev/null +++ b/mapstruct/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.2 + + + + com.ankurm + mapstruct + 1.0.0 + mapstruct + MapStruct on Spring Boot 4: compile-time DTO mapping with Java records + + + 25 + 1.6.3 + 7.0.0 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + com.github.dozermapper + dozer-core + ${dozer.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/mapstruct/scripts/capture-facts.sh b/mapstruct/scripts/capture-facts.sh new file mode 100644 index 0000000..7e2d737 --- /dev/null +++ b/mapstruct/scripts/capture-facts.sh @@ -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" diff --git a/mapstruct/scripts/compile-errors.sh b/mapstruct/scripts/compile-errors.sh new file mode 100644 index 0000000..205c3fb --- /dev/null +++ b/mapstruct/scripts/compile-errors.sh @@ -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 <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 diff --git a/mapstruct/scripts/run-all.sh b/mapstruct/scripts/run-all.sh new file mode 100644 index 0000000..f6f9c41 --- /dev/null +++ b/mapstruct/scripts/run-all.sh @@ -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 diff --git a/mapstruct/src/broken/ambiguous/BrokenMapper.java b/mapstruct/src/broken/ambiguous/BrokenMapper.java new file mode 100644 index 0000000..b95a434 --- /dev/null +++ b/mapstruct/src/broken/ambiguous/BrokenMapper.java @@ -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; + } +} diff --git a/mapstruct/src/broken/cannot-map/BrokenMapper.java b/mapstruct/src/broken/cannot-map/BrokenMapper.java new file mode 100644 index 0000000..2a1f7bb --- /dev/null +++ b/mapstruct/src/broken/cannot-map/BrokenMapper.java @@ -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); +} diff --git a/mapstruct/src/broken/decorator-constructor/BrokenMapper.java b/mapstruct/src/broken/decorator-constructor/BrokenMapper.java new file mode 100644 index 0000000..743f2ee --- /dev/null +++ b/mapstruct/src/broken/decorator-constructor/BrokenMapper.java @@ -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); +} diff --git a/mapstruct/src/broken/decorator-constructor/BrokenMapperDecorator.java b/mapstruct/src/broken/decorator-constructor/BrokenMapperDecorator.java new file mode 100644 index 0000000..5f68a9c --- /dev/null +++ b/mapstruct/src/broken/decorator-constructor/BrokenMapperDecorator.java @@ -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); + } +} diff --git a/mapstruct/src/broken/lombok/LombokMapper.java b/mapstruct/src/broken/lombok/LombokMapper.java new file mode 100644 index 0000000..9251e76 --- /dev/null +++ b/mapstruct/src/broken/lombok/LombokMapper.java @@ -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); +} diff --git a/mapstruct/src/broken/missing-source/BrokenMapper.java b/mapstruct/src/broken/missing-source/BrokenMapper.java new file mode 100644 index 0000000..e3d9862 --- /dev/null +++ b/mapstruct/src/broken/missing-source/BrokenMapper.java @@ -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); +} diff --git a/mapstruct/src/broken/processor-not-run/UsesMapper.java b/mapstruct/src/broken/processor-not-run/UsesMapper.java new file mode 100644 index 0000000..add9711 --- /dev/null +++ b/mapstruct/src/broken/processor-not-run/UsesMapper.java @@ -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"))); + } +} diff --git a/mapstruct/src/broken/unmapped-target/BrokenMapper.java b/mapstruct/src/broken/unmapped-target/BrokenMapper.java new file mode 100644 index 0000000..56007e0 --- /dev/null +++ b/mapstruct/src/broken/unmapped-target/BrokenMapper.java @@ -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); +} diff --git a/mapstruct/src/broken/update-record/BrokenMapper.java b/mapstruct/src/broken/update-record/BrokenMapper.java new file mode 100644 index 0000000..ecbd830 --- /dev/null +++ b/mapstruct/src/broken/update-record/BrokenMapper.java @@ -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); +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/ClockConfig.java b/mapstruct/src/main/java/com/ankurm/mapstruct/ClockConfig.java new file mode 100644 index 0000000..f82a61c --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/ClockConfig.java @@ -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(); + } +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/MapstructApplication.java b/mapstruct/src/main/java/com/ankurm/mapstruct/MapstructApplication.java new file mode 100644 index 0000000..edc6411 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/MapstructApplication.java @@ -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); + } +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/domain/Address.java b/mapstruct/src/main/java/com/ankurm/mapstruct/domain/Address.java new file mode 100644 index 0000000..9665028 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/domain/Address.java @@ -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; } +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/domain/Customer.java b/mapstruct/src/main/java/com/ankurm/mapstruct/domain/Customer.java new file mode 100644 index 0000000..ef75cb9 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/domain/Customer.java @@ -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; } +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/domain/Order.java b/mapstruct/src/main/java/com/ankurm/mapstruct/domain/Order.java new file mode 100644 index 0000000..81b9bbc --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/domain/Order.java @@ -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; } +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/domain/OrderLine.java b/mapstruct/src/main/java/com/ankurm/mapstruct/domain/OrderLine.java new file mode 100644 index 0000000..4a28fe9 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/domain/OrderLine.java @@ -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; } +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/domain/OrderStatus.java b/mapstruct/src/main/java/com/ankurm/mapstruct/domain/OrderStatus.java new file mode 100644 index 0000000..9e93ccc --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/domain/OrderStatus.java @@ -0,0 +1,3 @@ +package com.ankurm.mapstruct.domain; + +public enum OrderStatus { NEW, PAID, SHIPPED, CANCELLED } diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/dto/AddressDto.java b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/AddressDto.java new file mode 100644 index 0000000..c0e7178 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/AddressDto.java @@ -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) { } diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerDto.java b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerDto.java new file mode 100644 index 0000000..68dc289 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerDto.java @@ -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) { } diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerPatch.java b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerPatch.java new file mode 100644 index 0000000..e23dd7f --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerPatch.java @@ -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) { } diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerRequest.java b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerRequest.java new file mode 100644 index 0000000..bed5e4e --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/CustomerRequest.java @@ -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) { } diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/dto/InvoiceDto.java b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/InvoiceDto.java new file mode 100644 index 0000000..32fef49 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/InvoiceDto.java @@ -0,0 +1,5 @@ +package com.ankurm.mapstruct.dto; + +import java.math.BigDecimal; + +public record InvoiceDto(Long id, String status, BigDecimal total, String display) { } diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/dto/OrderDto.java b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/OrderDto.java new file mode 100644 index 0000000..0f3bd24 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/OrderDto.java @@ -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) { } diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/dto/OrderLineDto.java b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/OrderLineDto.java new file mode 100644 index 0000000..78b9938 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/OrderLineDto.java @@ -0,0 +1,5 @@ +package com.ankurm.mapstruct.dto; + +import java.math.BigDecimal; + +public record OrderLineDto(String sku, int quantity, BigDecimal unitPrice, BigDecimal lineTotal) { } diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/dto/ReceiptDto.java b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/ReceiptDto.java new file mode 100644 index 0000000..3166c09 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/dto/ReceiptDto.java @@ -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); } + } +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/AddressMapper.java b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/AddressMapper.java new file mode 100644 index 0000000..fdbf6fc --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/AddressMapper.java @@ -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); +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/CentralConfig.java b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/CentralConfig.java new file mode 100644 index 0000000..ae54111 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/CentralConfig.java @@ -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 { +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/CustomerMapper.java b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/CustomerMapper.java new file mode 100644 index 0000000..603218c --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/CustomerMapper.java @@ -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); +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/InvoiceMapper.java b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/InvoiceMapper.java new file mode 100644 index 0000000..e98e81e --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/InvoiceMapper.java @@ -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); +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/InvoiceMapperDecorator.java b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/InvoiceMapperDecorator.java new file mode 100644 index 0000000..9241147 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/InvoiceMapperDecorator.java @@ -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()); + } +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/LenientOrderMapper.java b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/LenientOrderMapper.java new file mode 100644 index 0000000..9a1439e --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/LenientOrderMapper.java @@ -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); +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/OrderMapper.java b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/OrderMapper.java new file mode 100644 index 0000000..03e7b04 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/OrderMapper.java @@ -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); + } +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/ReceiptMapper.java b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/ReceiptMapper.java new file mode 100644 index 0000000..354d264 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/mapper/ReceiptMapper.java @@ -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)"); + } +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/web/CustomerController.java b/mapstruct/src/main/java/com/ankurm/mapstruct/web/CustomerController.java new file mode 100644 index 0000000..8badfe3 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/web/CustomerController.java @@ -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); + } +} diff --git a/mapstruct/src/main/java/com/ankurm/mapstruct/web/CustomerRepository.java b/mapstruct/src/main/java/com/ankurm/mapstruct/web/CustomerRepository.java new file mode 100644 index 0000000..5cce8b6 --- /dev/null +++ b/mapstruct/src/main/java/com/ankurm/mapstruct/web/CustomerRepository.java @@ -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("Asha.Mehta@Example.com"); + 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; + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/BenchmarkTests.java b/mapstruct/src/test/java/com/ankurm/mapstruct/BenchmarkTests.java new file mode 100644 index 0000000..072efda --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/BenchmarkTests.java @@ -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(); + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/ContextConfigurationMapperTest.java b/mapstruct/src/test/java/com/ankurm/mapstruct/ContextConfigurationMapperTest.java new file mode 100644 index 0000000..1a9c4ea --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/ContextConfigurationMapperTest.java @@ -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"); + } + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/Fixtures.java b/mapstruct/src/test/java/com/ankurm/mapstruct/Fixtures.java new file mode 100644 index 0000000..5bb9573 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/Fixtures.java @@ -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()); + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/HooksTests.java b/mapstruct/src/test/java/com/ankurm/mapstruct/HooksTests.java new file mode 100644 index 0000000..9805c28 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/HooksTests.java @@ -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); + } + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/MappingBasicsTests.java b/mapstruct/src/test/java/com/ankurm/mapstruct/MappingBasicsTests.java new file mode 100644 index 0000000..6ed7e62 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/MappingBasicsTests.java @@ -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", " Ravi.Iyer@Example.COM ", 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("ravi.iyer@example.com"); + 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); + } + +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/NullHandlingAndUpdateTests.java b/mapstruct/src/test/java/com/ankurm/mapstruct/NullHandlingAndUpdateTests.java new file mode 100644 index 0000000..6d562ce --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/NullHandlingAndUpdateTests.java @@ -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=Asha.Mehta@Example.com, 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); + } + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/SpringBootMapperTest.java b/mapstruct/src/test/java/com/ankurm/mapstruct/SpringBootMapperTest.java new file mode 100644 index 0000000..a37a1e4 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/SpringBootMapperTest.java @@ -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"); + } + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/TestingStrategiesTests.java b/mapstruct/src/test/java/com/ankurm/mapstruct/TestingStrategiesTests.java new file mode 100644 index 0000000..dfa7ce7 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/TestingStrategiesTests.java @@ -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("other@example.com"), 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); + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/Transcript.java b/mapstruct/src/test/java/com/ankurm/mapstruct/Transcript.java new file mode 100644 index 0000000..a09b328 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/Transcript.java @@ -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); + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/WebJsonTests.java b/mapstruct/src/test/java/com/ankurm/mapstruct/WebJsonTests.java new file mode 100644 index 0000000..2f54458 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/WebJsonTests.java @@ -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":" Ravi.Iyer@Example.COM ","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\":\"ravi.iyer@example.com\"").contains("\"fullName\":\"Ravi Iyer\""); + assertThat(patched).contains("\"nickname\":\"ash\"").contains("\"email\":\"Asha.Mehta@Example.com\""); + assertThat(missing).isEqualTo(404); + } + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/probe/CycleContext.java b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/CycleContext.java new file mode 100644 index 0000000..2b1064f --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/CycleContext.java @@ -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); + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/probe/CycleMapper.java b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/CycleMapper.java new file mode 100644 index 0000000..7a723ea --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/CycleMapper.java @@ -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); +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/probe/FactoryTrapMapper.java b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/FactoryTrapMapper.java new file mode 100644 index 0000000..b07b670 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/FactoryTrapMapper.java @@ -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); + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/probe/HandWritten.java b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/HandWritten.java new file mode 100644 index 0000000..d3912f3 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/HandWritten.java @@ -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; + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/probe/HookOrderMapper.java b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/HookOrderMapper.java new file mode 100644 index 0000000..143f9c6 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/HookOrderMapper.java @@ -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)"); + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/probe/Probes.java b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/Probes.java new file mode 100644 index 0000000..54ec6a0 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/Probes.java @@ -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; } + } +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/probe/ViewMapper.java b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/ViewMapper.java new file mode 100644 index 0000000..ec63a66 --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/ViewMapper.java @@ -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); +} diff --git a/mapstruct/src/test/java/com/ankurm/mapstruct/probe/Views.java b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/Views.java new file mode 100644 index 0000000..566d97c --- /dev/null +++ b/mapstruct/src/test/java/com/ankurm/mapstruct/probe/Views.java @@ -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; } + } +} diff --git a/startup-runners/README.md b/startup-runners/README.md new file mode 100644 index 0000000..3e6cafa --- /dev/null +++ b/startup-runners/README.md @@ -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()` | diff --git a/startup-runners/output/00-hello.txt b/startup-runners/output/00-hello.txt new file mode 100644 index 0000000..b6fa36a --- /dev/null +++ b/startup-runners/output/00-hello.txt @@ -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 diff --git a/startup-runners/output/01-startup-hooks-in-order.txt b/startup-runners/output/01-startup-hooks-in-order.txt new file mode 100644 index 0000000..5a710e2 --- /dev/null +++ b/startup-runners/output/01-startup-hooks-in-order.txt @@ -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 diff --git a/startup-runners/output/02-order-annotation-and-ordered.txt b/startup-runners/output/02-order-annotation-and-ordered.txt new file mode 100644 index 0000000..14e8d31 --- /dev/null +++ b/startup-runners/output/02-order-annotation-and-ordered.txt @@ -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) diff --git a/startup-runners/output/03-ties-have-no-promised-order.txt b/startup-runners/output/03-ties-have-no-promised-order.txt new file mode 100644 index 0000000..ded787f --- /dev/null +++ b/startup-runners/output/03-ties-have-no-promised-order.txt @@ -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 diff --git a/startup-runners/output/04-arguments.txt b/startup-runners/output/04-arguments.txt new file mode 100644 index 0000000..05b25fa --- /dev/null +++ b/startup-runners/output/04-arguments.txt @@ -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 diff --git a/startup-runners/output/05-runner-failure.txt b/startup-runners/output/05-runner-failure.txt new file mode 100644 index 0000000..a51c9f6 --- /dev/null +++ b/startup-runners/output/05-runner-failure.txt @@ -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 diff --git a/startup-runners/output/06-ready-listener-failure.txt b/startup-runners/output/06-ready-listener-failure.txt new file mode 100644 index 0000000..a5669bc --- /dev/null +++ b/startup-runners/output/06-ready-listener-failure.txt @@ -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 diff --git a/startup-runners/output/07-blocking-runner-readiness.txt b/startup-runners/output/07-blocking-runner-readiness.txt new file mode 100644 index 0000000..52be479 --- /dev/null +++ b/startup-runners/output/07-blocking-runner-readiness.txt @@ -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 diff --git a/startup-runners/output/08-async-runner.txt b/startup-runners/output/08-async-runner.txt new file mode 100644 index 0000000..75d3d29 --- /dev/null +++ b/startup-runners/output/08-async-runner.txt @@ -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 diff --git a/startup-runners/output/09-smartlifecycle-phases.txt b/startup-runners/output/09-smartlifecycle-phases.txt new file mode 100644 index 0000000..6b59f5c --- /dev/null +++ b/startup-runners/output/09-smartlifecycle-phases.txt @@ -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 diff --git a/startup-runners/output/10-lifecycle-beans-in-a-web-app.txt b/startup-runners/output/10-lifecycle-beans-in-a-web-app.txt new file mode 100644 index 0000000..0090b91 --- /dev/null +++ b/startup-runners/output/10-lifecycle-beans-in-a-web-app.txt @@ -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 diff --git a/startup-runners/output/11-smartlifecycle-start-fails.txt b/startup-runners/output/11-smartlifecycle-start-fails.txt new file mode 100644 index 0000000..ba8f0c5 --- /dev/null +++ b/startup-runners/output/11-smartlifecycle-start-fails.txt @@ -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 diff --git a/startup-runners/output/12-smartlifecycle-forgot-isrunning.txt b/startup-runners/output/12-smartlifecycle-forgot-isrunning.txt new file mode 100644 index 0000000..e5aaba8 --- /dev/null +++ b/startup-runners/output/12-smartlifecycle-forgot-isrunning.txt @@ -0,0 +1,6 @@ +# isRunning() always false: start() is called, stop() never is + +--- startup --- +start() was called +--- after context.close() --- +(nothing) diff --git a/startup-runners/output/13-spring-boot-test-runs-the-hooks.txt b/startup-runners/output/13-spring-boot-test-runs-the-hooks.txt new file mode 100644 index 0000000..635daf7 --- /dev/null +++ b/startup-runners/output/13-spring-boot-test-runs-the-hooks.txt @@ -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 diff --git a/startup-runners/output/14-webmvctest-slice-hooks.txt b/startup-runners/output/14-webmvctest-slice-hooks.txt new file mode 100644 index 0000000..c48f7aa --- /dev/null +++ b/startup-runners/output/14-webmvctest-slice-hooks.txt @@ -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 = [] diff --git a/startup-runners/output/15-callrunners-source.txt b/startup-runners/output/15-callrunners-source.txt new file mode 100644 index 0000000..37f0efa --- /dev/null +++ b/startup-runners/output/15-callrunners-source.txt @@ -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)); + } diff --git a/startup-runners/output/16-javap-callrunners.txt b/startup-runners/output/16-javap-callrunners.txt new file mode 100644 index 0000000..52b1917 --- /dev/null +++ b/startup-runners/output/16-javap-callrunners.txt @@ -0,0 +1,31 @@ +# javap -p -c on spring-boot-4.1.2.jar: the compiled SpringApplication.callRunners + +--- javap -p org.springframework.boot.Runner / CommandLineRunner / ApplicationRunner --- +Compiled from "Runner.java" +interface org.springframework.boot.Runner { +} +Compiled from "CommandLineRunner.java" +public interface org.springframework.boot.CommandLineRunner extends org.springframework.boot.Runner { + public abstract void run(java.lang.String...) throws java.lang.Exception; +} +Compiled from "ApplicationRunner.java" +public interface org.springframework.boot.ApplicationRunner extends org.springframework.boot.Runner { + public abstract void run(org.springframework.boot.ApplicationArguments) throws java.lang.Exception; +} + +--- invoke* instructions inside callRunners --- +invokeinterface #393, 1 // InterfaceMethod org/springframework/context/ConfigurableApplicationContext.getBeanFactory:()Lorg/springframework/beans/factory/config/ConfigurableListableBeanFactory; +invokeinterface #966, 2 // InterfaceMethod org/springframework/beans/factory/config/ConfigurableListableBeanFactory.getBeanNamesForType:(Ljava/lang/Class;)[Ljava/lang/String; +new #970 // class java/util/IdentityHashMap +invokespecial #972 // Method java/util/IdentityHashMap."<init>":()V +invokeinterface #973, 3 // InterfaceMethod org/springframework/beans/factory/config/ConfigurableListableBeanFactory.getBean:(Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; +invokeinterface #977, 3 // InterfaceMethod java/util/Map.put:(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +invokevirtual #983 // Method getOrderComparator:(Lorg/springframework/beans/factory/config/ConfigurableListableBeanFactory;)Lorg/springframework/core/OrderComparator; +new #987 // class org/springframework/boot/SpringApplication$FactoryAwareOrderSourceProvider +invokespecial #989 // Method org/springframework/boot/SpringApplication$FactoryAwareOrderSourceProvider."<init>":(Lorg/springframework/beans/factory/config/ConfigurableBeanFactory;Ljava/util/Map;)V +invokevirtual #992 // Method org/springframework/core/OrderComparator.withSourceProvider:(Lorg/springframework/core/OrderComparator$OrderSourceProvider;)Ljava/util/Comparator; +invokeinterface #998, 1 // InterfaceMethod java/util/Map.keySet:()Ljava/util/Set; +invokeinterface #1001, 1 // InterfaceMethod java/util/Set.stream:()Ljava/util/stream/Stream; +invokeinterface #1002, 2 // InterfaceMethod java/util/stream/Stream.sorted:(Ljava/util/Comparator;)Ljava/util/stream/Stream; +invokedynamic #1006, 0 // InvokeDynamic #12:accept:(Lorg/springframework/boot/SpringApplication;Lorg/springframework/boot/ApplicationArguments;)Ljava/util/function/Consumer; +invokeinterface #1009, 2 // InterfaceMethod java/util/stream/Stream.forEach:(Ljava/util/function/Consumer;)V diff --git a/startup-runners/output/17-lifecycleprocessor-source.txt b/startup-runners/output/17-lifecycleprocessor-source.txt new file mode 100644 index 0000000..a2ffdb1 --- /dev/null +++ b/startup-runners/output/17-lifecycleprocessor-source.txt @@ -0,0 +1,97 @@ +# spring-context 7.0.10 sources: DefaultLifecycleProcessor, the object that starts and stops SmartLifecycle beans + +--- doStart: start() is called only when isRunning() is false --- + private void doStart(Map<String, ? extends Lifecycle> lifecycleBeans, String beanName, + boolean autoStartupOnly, @Nullable List<CompletableFuture<?>> futures) { + + Lifecycle bean = lifecycleBeans.remove(beanName); + if (bean != null && bean != this) { + String[] dependenciesForBean = getBeanFactory().getDependenciesForBean(beanName); + for (String dependency : dependenciesForBean) { + doStart(lifecycleBeans, dependency, autoStartupOnly, futures); + } + if (!bean.isRunning() && (!autoStartupOnly || toBeStarted(beanName, bean))) { + if (futures != null) { + futures.add(CompletableFuture.runAsync(() -> doStart(beanName, bean), getBootstrapExecutor())); + } + else { + doStart(beanName, bean); + } + } + } + } + +--- doStop: stop() is called only when isRunning() is true --- + private void doStop(Map<String, ? extends Lifecycle> lifecycleBeans, final String beanName, + boolean pauseableOnly, final CountDownLatch latch, final Set<String> countDownBeanNames) { + + Lifecycle bean = lifecycleBeans.remove(beanName); + if (bean != null) { + String[] dependentBeans = getBeanFactory().getDependentBeans(beanName); + for (String dependentBean : dependentBeans) { + doStop(lifecycleBeans, dependentBean, pauseableOnly, latch, countDownBeanNames); + } + try { + if (bean.isRunning()) { + Set<String> stoppedBeans = this.stoppedBeans; + if (stoppedBeans != null) { + stoppedBeans.add(beanName); + } + if (bean instanceof SmartLifecycle smartLifecycle) { + if (!pauseableOnly || smartLifecycle.isPauseable()) { + if (logger.isTraceEnabled()) { + logger.trace("Asking bean '" + beanName + "' of type [" + + bean.getClass().getName() + "] to stop"); + } + countDownBeanNames.add(beanName); + smartLifecycle.stop(() -> { + latch.countDown(); + countDownBeanNames.remove(beanName); + if (logger.isDebugEnabled()) { + logger.debug("Bean '" + beanName + "' completed its stop procedure"); + } + }); + } + else { + // Don't wait for beans that aren't pauseable... + latch.countDown(); + } + } + else if (!pauseableOnly) { + if (logger.isTraceEnabled()) { + logger.trace("Stopping bean '" + beanName + "' of type [" + + bean.getClass().getName() + "]"); + } + bean.stop(); + if (logger.isDebugEnabled()) { + logger.debug("Successfully stopped bean '" + beanName + "'"); + } + } + } + else if (bean instanceof SmartLifecycle) { + // Don't wait for beans that aren't running... + latch.countDown(); + } + } + catch (Throwable ex) { + if (logger.isWarnEnabled()) { + logger.warn("Failed to stop bean '" + beanName + "'", ex); + } + if (bean instanceof SmartLifecycle) { + latch.countDown(); + } + } + } + } + +--- isAutoStartupCandidate / toBeStarted: what isAutoStartup() decides --- + private boolean isAutoStartupCandidate(String beanName, Lifecycle bean) { + Set<String> stoppedBeans = this.stoppedBeans; + return (stoppedBeans != null ? stoppedBeans.contains(beanName) : + (bean instanceof SmartLifecycle smartLifecycle && smartLifecycle.isAutoStartup())); + } + private boolean toBeStarted(String beanName, Lifecycle bean) { + Set<String> stoppedBeans = this.stoppedBeans; + return (stoppedBeans != null ? stoppedBeans.contains(beanName) : + (!(bean instanceof SmartLifecycle smartLifecycle) || smartLifecycle.isAutoStartup())); + } diff --git a/startup-runners/pom.xml b/startup-runners/pom.xml new file mode 100644 index 0000000..5e62fea --- /dev/null +++ b/startup-runners/pom.xml @@ -0,0 +1,61 @@ +<?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>startup-runners</artifactId> + <version>1.0.0</version> + <name>startup-runners</name> + <description>CommandLineRunner vs ApplicationRunner vs ApplicationReadyEvent vs SmartLifecycle on Spring Boot 4</description> + + <properties> + <java.version>25</java.version> + </properties> + + <dependencies> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-webmvc</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-actuator</artifactId> + </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> + </dependencies> + + <build> + <plugins> + <plugin> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-maven-plugin</artifactId> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-surefire-plugin</artifactId> + <configuration> + <!-- some scenarios start a second JVM with the test classpath, so give it a real classpath, not a manifest-only jar --> + <useManifestOnlyJar>false</useManifestOnlyJar> + </configuration> + </plugin> + </plugins> + </build> +</project> diff --git a/startup-runners/scripts/capture-facts.sh b/startup-runners/scripts/capture-facts.sh new file mode 100644 index 0000000..b6ab905 --- /dev/null +++ b/startup-runners/scripts/capture-facts.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Facts that come from the shipped jars and their sources rather than from running the scenarios: +# 15 SpringApplication.run / callRunners / EventPublishingRunListener, read from the 4.1.2 sources jar, +# and the same method in Boot 3.0.13 and 3.1.12 for contrast +# 16 the same method read out of the compiled 4.1.2 class with javap (what it really calls) +# 17 DefaultLifecycleProcessor: when start() and stop() are called +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p output target/facts +BOOT=4.1.2 +SPRING=7.0.10 +M2=~/.m2/repository + +fetch() { # url, destination: Maven Central rate-limits shared addresses, so retry politely + for i in 1 2 3 4 5 6; do + code=$(curl -s -o "$2" -w '%{http_code}' "$1" || true) + [ "$code" = 200 ] && return 0 + sleep $((i * 10)) + done + echo "could not fetch $1 (last status $code)" >&2; return 1 +} +src() { # groupPath artifact version + local f="target/facts/$2-$3-sources.jar" + [ -s "$f" ] || fetch "https://repo1.maven.org/maven2/$1/$2/$3/$2-$3-sources.jar" "$f" + echo "$f" +} +method() { # sources-jar file-in-jar start-regex -> prints from the first match to the first line that is a single tab + } + unzip -p "$1" "$2" | sed -n "/$3/,/^ }/p" +} + +BOOT_SRC=$(src org/springframework/boot spring-boot $BOOT) +OLD0=$(src org/springframework/boot spring-boot 3.0.13) +OLD1=$(src org/springframework/boot spring-boot 3.1.12) +FW_SRC=$(src org/springframework spring-context $SPRING) +APP=org/springframework/boot/SpringApplication.java + +{ + echo "# Spring Boot $BOOT sources: the order SpringApplication.run() calls things in, and callRunners" + echo + echo "--- SpringApplication.run(): from refresh to ready (lines containing the calls) ---" + unzip -p "$BOOT_SRC" $APP | sed -n '/public ConfigurableApplicationContext run(String... args)/,/^ }/p' \ + | grep -n -E 'refreshContext|afterRefresh|listeners\.(started|ready)|callRunners|handleRunFailure' | sed 's/^\([0-9]*\):\s*/\1: /' + echo + echo "--- SpringApplication.callRunners ---" + method "$BOOT_SRC" $APP 'private void callRunners' + echo + echo "--- SpringApplication.callRunner (what happens to a bean that is both kinds) ---" + method "$BOOT_SRC" $APP 'private void callRunner(Runner runner' + echo + echo "--- SpringApplication.callRunner(Class, Runner, ThrowingConsumer) (where 'Failed to execute' comes from) ---" + method "$BOOT_SRC" $APP 'private <R extends Runner> void callRunner' + echo + echo "--- EventPublishingRunListener.started / ready ---" + method "$BOOT_SRC" org/springframework/boot/context/event/EventPublishingRunListener.java 'public void started' + method "$BOOT_SRC" org/springframework/boot/context/event/EventPublishingRunListener.java 'public void ready' + echo + echo "--- the marker interface that both runners extend ---" + unzip -p "$BOOT_SRC" org/springframework/boot/Runner.java | grep -E '^(interface|public interface)' + unzip -p "$BOOT_SRC" org/springframework/boot/CommandLineRunner.java | grep -E '^public interface' + unzip -p "$BOOT_SRC" org/springframework/boot/ApplicationRunner.java | grep -E '^public interface' + echo + echo "--- the same method in Spring Boot 3.0.13 ---" + method "$OLD0" $APP 'private void callRunners' + echo + echo "--- the same method in Spring Boot 3.1.12 ---" + method "$OLD1" $APP 'private void callRunners' +} > output/15-callrunners-source.txt + +JAR=$(find "$M2/org/springframework/boot/spring-boot/$BOOT" -name "spring-boot-$BOOT.jar" | head -1) +{ + echo "# javap -p -c on spring-boot-$BOOT.jar: the compiled SpringApplication.callRunners" + echo + echo "--- javap -p org.springframework.boot.Runner / CommandLineRunner / ApplicationRunner ---" + javap -p -cp "$JAR" org.springframework.boot.Runner org.springframework.boot.CommandLineRunner org.springframework.boot.ApplicationRunner + echo + echo "--- invoke* instructions inside callRunners ---" + javap -p -c -cp "$JAR" org.springframework.boot.SpringApplication \ + | sed -n '/private void callRunners(/,/^$/p' | grep -E 'invoke|new ' | sed 's/^ *[0-9]*: //' +} > output/16-javap-callrunners.txt + +CTX=$(find "$M2/org/springframework/spring-context/$SPRING" -name "spring-context-$SPRING.jar" | head -1) +LP=org/springframework/context/support/DefaultLifecycleProcessor.java +{ + echo "# spring-context $SPRING sources: DefaultLifecycleProcessor, the object that starts and stops SmartLifecycle beans" + echo + echo "--- doStart: start() is called only when isRunning() is false ---" + method "$FW_SRC" $LP 'private void doStart(Map' + echo + echo "--- doStop: stop() is called only when isRunning() is true ---" + method "$FW_SRC" $LP 'private void doStop(Map' + echo + echo "--- isAutoStartupCandidate / toBeStarted: what isAutoStartup() decides ---" + method "$FW_SRC" $LP 'private boolean isAutoStartupCandidate' + method "$FW_SRC" $LP 'private boolean toBeStarted' +} > output/17-lifecycleprocessor-source.txt +ls -1 output | tail -3 diff --git a/startup-runners/scripts/run-all.sh b/startup-runners/scripts/run-all.sh new file mode 100644 index 0000000..31b5fc1 --- /dev/null +++ b/startup-runners/scripts/run-all.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Regenerates every file under output/. +# +# ./scripts/run-all.sh +# +# Needs a JDK 25 and Maven 3.9. Transcripts 01-14 are written by the test suite, so each figure in the +# article is an assertion that fails the build if it stops being true. Transcripts 15-17 are read out of the +# Spring Boot / Spring Framework sources and jars by capture-facts.sh (network access to Maven Central needed once). +# Transcript 03 (runners without @Order) is the one that is allowed to change from run to run: that is its point. +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "== test suite (transcripts 01-14)" +mvn -B test + +echo "== facts read from sources and jars (15-17)" +./scripts/capture-facts.sh + +echo +echo "output:" +ls -1 output diff --git a/startup-runners/src/main/java/com/ankurm/runners/args/ArgsApp.java b/startup-runners/src/main/java/com/ankurm/runners/args/ArgsApp.java new file mode 100644 index 0000000..a26a5db --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/args/ArgsApp.java @@ -0,0 +1,44 @@ +package com.ankurm.runners.args; + +import com.ankurm.runners.support.Trace; +import java.util.Arrays; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** Scenario 4: the same command line, as the two runner flavours see it. */ +@SpringBootApplication +public class ArgsApp { + + public static void main(String[] args) { + SpringApplication.run(ArgsApp.class, args); + } + + @Component + @Order(1) + static class Cli implements CommandLineRunner { + @Override + public void run(String... args) { + Trace.add("CommandLineRunner String[] args = %s", Arrays.toString(args)); + } + } + + @Component + @Order(2) + static class App implements ApplicationRunner { + @Override + public void run(ApplicationArguments args) { + Trace.add("ApplicationRunner getSourceArgs() = %s", Arrays.toString(args.getSourceArgs())); + Trace.add("ApplicationRunner getOptionNames() = %s", args.getOptionNames()); + Trace.add("ApplicationRunner getOptionValues(name) = %s", args.getOptionValues("name")); + Trace.add("ApplicationRunner getOptionValues(verbose) = %s", args.getOptionValues("verbose")); + Trace.add("ApplicationRunner getOptionValues(missing) = %s", args.getOptionValues("missing")); + Trace.add("ApplicationRunner containsOption(verbose) = %s", args.containsOption("verbose")); + Trace.add("ApplicationRunner getNonOptionArgs() = %s", args.getNonOptionArgs()); + } + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/blocking/BlockingApp.java b/startup-runners/src/main/java/com/ankurm/runners/blocking/BlockingApp.java new file mode 100644 index 0000000..275f1d9 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/blocking/BlockingApp.java @@ -0,0 +1,65 @@ +package com.ankurm.runners.blocking; + +import com.ankurm.runners.support.Trace; +import java.util.concurrent.TimeUnit; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Scenarios 7 and 8: a web application whose runner takes a long time. With profile {@code blocking} the runner + * waits on a latch in the runner thread; with profile {@code async} it hands the same wait to a virtual thread and + * returns at once. The test opens and closes the latch, so nothing here depends on a sleep. + */ +@SpringBootApplication +public class BlockingApp { + + public static void main(String[] args) { + SpringApplication.run(BlockingApp.class, args); + } + + @RestController + static class Ping { + @GetMapping("/ping") + String ping() { + return "pong"; + } + } + + @Bean + @Profile("blocking") + CommandLineRunner blockingRunner() { + return args -> { + Trace.add("runner: started on %s, now waiting (think: warming a cache for 40 s)", Trace.where()); + Gate.RUNNER_STARTED.countDown(); + Gate.RELEASE.await(30, TimeUnit.SECONDS); + Trace.add("runner: finished, returning to SpringApplication.run"); + }; + } + + @Bean + @Profile("async") + CommandLineRunner asyncRunner() { + return args -> { + Thread.ofVirtual() + .uncaughtExceptionHandler((thread, e) -> { + Trace.add("background work on %s failed: %s", Trace.where(), e.getMessage()); + Gate.BACKGROUND_DONE.countDown(); + }) + .start(() -> { + Gate.RUNNER_STARTED.countDown(); + try { + Gate.RELEASE.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + throw new IllegalStateException("the warm-up blew up"); + }); + Trace.add("runner: started the work on a virtual thread and returned"); + }; + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/blocking/Gate.java b/startup-runners/src/main/java/com/ankurm/runners/blocking/Gate.java new file mode 100644 index 0000000..f388d2e --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/blocking/Gate.java @@ -0,0 +1,20 @@ +package com.ankurm.runners.blocking; + +import java.util.concurrent.CountDownLatch; + +/** Latches the test uses to freeze and release the slow work, so the scenario needs no sleeps. */ +public final class Gate { + + public static volatile CountDownLatch RUNNER_STARTED = new CountDownLatch(1); + public static volatile CountDownLatch RELEASE = new CountDownLatch(1); + public static volatile CountDownLatch BACKGROUND_DONE = new CountDownLatch(1); + + private Gate() { + } + + public static void reset() { + RUNNER_STARTED = new CountDownLatch(1); + RELEASE = new CountDownLatch(1); + BACKGROUND_DONE = new CountDownLatch(1); + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/failure/FailureApp.java b/startup-runners/src/main/java/com/ankurm/runners/failure/FailureApp.java new file mode 100644 index 0000000..b719719 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/failure/FailureApp.java @@ -0,0 +1,164 @@ +package com.ankurm.runners.failure; + +import com.ankurm.runners.support.Trace; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.ExitCodeGenerator; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.availability.ApplicationAvailability; +import org.springframework.boot.context.event.ApplicationFailedEvent; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Scenarios 5 and 6. Run it with {@code --spring.profiles.active=runner-fails}, {@code ready-fails}, {@code exit-code} + * or {@code checked} and look at the exit code and the console. + */ +@SpringBootApplication +public class FailureApp { + + public static void main(String[] args) { + SpringApplication.run(FailureApp.class, args); + } + + /** Always present: shows which of the startup events fire at all. */ + @Component + static class Observer { + + private final ApplicationAvailability availability; + + Observer(ApplicationAvailability availability) { + this.availability = availability; + } + + @EventListener + void started(ApplicationStartedEvent e) { + Trace.add("ApplicationStartedEvent"); + } + + @EventListener + void ready(ApplicationReadyEvent e) { + Trace.add("ApplicationReadyEvent (observer, readiness: %s)", availability.getReadinessState()); + } + + @EventListener + void failed(ApplicationFailedEvent e) { + Trace.add("ApplicationFailedEvent: %s", e.getException()); + } + + @EventListener + void closed(ContextClosedEvent e) { + Trace.add("ContextClosedEvent (the context is shutting down)"); + } + } + + @RestController + static class Ping { + @GetMapping("/ping") + String ping() { + return "pong"; + } + } + + // ---- profile runner-fails: the second of three runners throws ---------------------------------------- + + @Bean + @Profile("runner-fails") + @Order(1) + CommandLineRunner runnerOne() { + return args -> Trace.add("runner 1 ran"); + } + + @Bean + @Profile("runner-fails") + @Order(2) + ApplicationRunner runnerTwo() { + return args -> { + Trace.add("runner 2 is about to throw"); + throw new IllegalStateException("boom from runner 2"); + }; + } + + @Bean + @Profile("runner-fails") + @Order(3) + CommandLineRunner runnerThree() { + return args -> Trace.add("runner 3 ran"); + } + + // ---- profile ready-fails: an ApplicationReadyEvent listener throws ----------------------------------- + + @Component + @Profile("ready-fails") + static class ThrowingReadyListener { + + private final ApplicationAvailability availability; + private final Environment env; + + ThrowingReadyListener(ApplicationAvailability availability, Environment env) { + this.availability = availability; + this.env = env; + } + + @EventListener + @Order(1) + void ready(ApplicationReadyEvent e) throws Exception { + String port = env.getProperty("server.port"); + if (port != null) { + var response = java.net.http.HttpClient.newHttpClient().send( + java.net.http.HttpRequest.newBuilder(java.net.URI.create("http://localhost:" + port + "/ping")).build(), + java.net.http.HttpResponse.BodyHandlers.ofString()); + Trace.add("GET /ping from inside the listener -> %d %s", response.statusCode(), response.body()); + } + Trace.add("ready listener 1 is about to throw (readiness: %s)", availability.getReadinessState()); + throw new IllegalStateException("boom from the ready listener"); + } + + @EventListener + @Order(2) + void secondListener(ApplicationReadyEvent e) { + Trace.add("ready listener 2 ran"); + } + } + + // ---- profile exit-code: a runner throws an exception that carries its own exit code ------------------ + + @Bean + @Profile("exit-code") + CommandLineRunner exitCodeRunner() { + return args -> { + throw new ExitWith42Exception("the data file is missing"); + }; + } + + // ---- profile checked: a runner declares "throws Exception" and throws a checked one ------------------- + + @Bean + @Profile("checked") + CommandLineRunner checkedExceptionRunner() { + return args -> { + throw new java.io.IOException("cannot read data.csv"); + }; + } + + static class ExitWith42Exception extends RuntimeException implements ExitCodeGenerator { + ExitWith42Exception(String message) { + super(message); + } + + @Override + public int getExitCode() { + return 42; + } + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/hello/HelloApp.java b/startup-runners/src/main/java/com/ankurm/runners/hello/HelloApp.java new file mode 100644 index 0000000..5f7dbf0 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/hello/HelloApp.java @@ -0,0 +1,38 @@ +package com.ankurm.runners.hello; + +import java.util.Arrays; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; + +/** The smallest working example of all three hooks: each prints one line, so you can see the sequence. */ +@SpringBootApplication +public class HelloApp { + + public static void main(String[] args) { + SpringApplication.run(HelloApp.class, args); + } + + @Bean + @Order(1) + CommandLineRunner commandLineRunner() { + return args -> System.out.println("hello: CommandLineRunner args = " + Arrays.toString(args)); + } + + @Bean + @Order(2) + ApplicationRunner applicationRunner() { + return args -> System.out.println("hello: ApplicationRunner options = " + args.getOptionNames() + + ", non-option args = " + args.getNonOptionArgs()); + } + + @EventListener(ApplicationReadyEvent.class) + void onReady() { + System.out.println("hello: ApplicationReadyEvent listener"); + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/lifecycle/LifecycleApp.java b/startup-runners/src/main/java/com/ankurm/runners/lifecycle/LifecycleApp.java new file mode 100644 index 0000000..1bdc54a --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/lifecycle/LifecycleApp.java @@ -0,0 +1,72 @@ +package com.ankurm.runners.lifecycle; + +import com.ankurm.runners.support.Trace; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * Scenario 9: five {@link Svc} lifecycle beans with different phases, one that must be started by hand and one that + * takes 300 ms to stop, next to a runner and the startup events. + */ +@SpringBootApplication +public class LifecycleApp { + + public static void main(String[] args) { + SpringApplication.run(LifecycleApp.class, args); + } + + @Bean + Svc high() { + return new Svc("high", 100, true, 0); + } + + @Bean + Svc low() { + return new Svc("low", -100, true, 0); + } + + @Bean + Svc mid() { + return new Svc("mid", 0, true, 0); + } + + @Bean + Svc manual() { + return new Svc("manual", 50, false, 0); + } + + @Bean + Svc slow() { + return new Svc("slow", 200, true, 300); + } + + @Bean + CommandLineRunner runner() { + return args -> Trace.add("CommandLineRunner runs"); + } + + @Component + static class Events { + @EventListener + void started(ApplicationStartedEvent e) { + Trace.add("ApplicationStartedEvent"); + } + + @EventListener + void ready(ApplicationReadyEvent e) { + Trace.add("ApplicationReadyEvent"); + } + + @EventListener + void closing(ContextClosedEvent e) { + Trace.add("ContextClosedEvent (stop phase begins)"); + } + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/lifecycle/Svc.java b/startup-runners/src/main/java/com/ankurm/runners/lifecycle/Svc.java new file mode 100644 index 0000000..a80de35 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/lifecycle/Svc.java @@ -0,0 +1,72 @@ +package com.ankurm.runners.lifecycle; + +import com.ankurm.runners.support.Trace; +import org.springframework.context.SmartLifecycle; + +/** A {@link SmartLifecycle} that writes to the trace when it is started and stopped. */ +public class Svc implements SmartLifecycle { + + private final String name; + private final int phase; + private final boolean autoStartup; + private final long slowStopMillis; + private volatile boolean running; + + public Svc(String name, int phase, boolean autoStartup, long slowStopMillis) { + this.name = name; + this.phase = phase; + this.autoStartup = autoStartup; + this.slowStopMillis = slowStopMillis; + } + + @Override + public void start() { + running = true; + Trace.add("start %-6s (phase %d) on %s", name, phase, Trace.where()); + } + + @Override + public void stop() { + running = false; + Trace.add("stop %-6s (phase %d) on %s", name, phase, Trace.where()); + } + + /** + * The asynchronous stop: Spring waits for the callback (up to spring.lifecycle.timeout-per-shutdown-phase) before it moves + * on to the next phase. A bean with no slow shutdown simply keeps the default, which calls {@link #stop()} and then + * the callback. + */ + @Override + public void stop(Runnable callback) { + if (slowStopMillis == 0) { + SmartLifecycle.super.stop(callback); + return; + } + Trace.add("stop %-6s (phase %d) begins, will finish in %d ms", name, phase, slowStopMillis); + new Thread(() -> { + try { + Thread.sleep(slowStopMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + running = false; + Trace.add("stop %-6s finished, calling the callback", name); + callback.run(); + }, "slow-stop").start(); + } + + @Override + public boolean isRunning() { + return running; + } + + @Override + public boolean isAutoStartup() { + return autoStartup; + } + + @Override + public int getPhase() { + return phase; + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/lifeforgetful/ForgetfulApp.java b/startup-runners/src/main/java/com/ankurm/runners/lifeforgetful/ForgetfulApp.java new file mode 100644 index 0000000..7584da4 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/lifeforgetful/ForgetfulApp.java @@ -0,0 +1,37 @@ +package com.ankurm.runners.lifeforgetful; + +import com.ankurm.runners.support.Trace; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.annotation.Bean; + +/** Scenario 10b: a {@link SmartLifecycle} whose {@code isRunning()} always says false. */ +@SpringBootApplication +public class ForgetfulApp { + + public static void main(String[] args) { + SpringApplication.run(ForgetfulApp.class, args); + } + + @Bean + SmartLifecycle forgetful() { + return new SmartLifecycle() { + @Override + public void start() { + Trace.add("start() was called"); + // ... and the author forgot to remember that. isRunning() below never turns true. + } + + @Override + public void stop() { + Trace.add("stop() was called"); + } + + @Override + public boolean isRunning() { + return false; + } + }; + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/lifestartfails/StartFailsApp.java b/startup-runners/src/main/java/com/ankurm/runners/lifestartfails/StartFailsApp.java new file mode 100644 index 0000000..9171fbf --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/lifestartfails/StartFailsApp.java @@ -0,0 +1,88 @@ +package com.ankurm.runners.lifestartfails; + +import com.ankurm.runners.support.Trace; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** Scenario 10a: a {@link SmartLifecycle#start()} that throws, next to a healthy one and a runner. */ +@SpringBootApplication +public class StartFailsApp { + + public static void main(String[] args) { + SpringApplication.run(StartFailsApp.class, args); + } + + @Bean + SmartLifecycle healthy() { + return new Named("healthy", 0, false); + } + + @Bean + SmartLifecycle broken() { + return new Named("broken", 10, true); + } + + @Bean + CommandLineRunner runner() { + return args -> Trace.add("CommandLineRunner runs"); + } + + @Component + static class Events { + @EventListener + void started(ApplicationStartedEvent e) { + Trace.add("ApplicationStartedEvent"); + } + + @EventListener + void closing(ContextClosedEvent e) { + Trace.add("ContextClosedEvent"); + } + } + + static class Named implements SmartLifecycle { + + private final String name; + private final int phase; + private final boolean failOnStart; + private volatile boolean running; + + Named(String name, int phase, boolean failOnStart) { + this.name = name; + this.phase = phase; + this.failOnStart = failOnStart; + } + + @Override + public void start() { + Trace.add("start %s (phase %d)", name, phase); + if (failOnStart) { + throw new IllegalStateException("could not connect to the broker"); + } + running = true; + } + + @Override + public void stop() { + running = false; + Trace.add("stop %s", name); + } + + @Override + public boolean isRunning() { + return running; + } + + @Override + public int getPhase() { + return phase; + } + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/order/EventProbe.java b/startup-runners/src/main/java/com/ankurm/runners/order/EventProbe.java new file mode 100644 index 0000000..09bc347 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/order/EventProbe.java @@ -0,0 +1,47 @@ +package com.ankurm.runners.order; + +import com.ankurm.runners.support.Trace; +import org.springframework.boot.availability.ApplicationAvailability; +import org.springframework.boot.availability.AvailabilityChangeEvent; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.boot.web.server.context.WebServerInitializedEvent; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** Listens to every startup event and also asks {@link ApplicationAvailability} what state the app thinks it is in. */ +@Component +class EventProbe { + + private final ApplicationAvailability availability; + + EventProbe(ApplicationAvailability availability) { + this.availability = availability; + } + + @EventListener + void webServer(WebServerInitializedEvent event) { + Trace.add("WebServerInitializedEvent (port is open)"); + } + + @EventListener + void refreshed(ContextRefreshedEvent event) { + Trace.add("ContextRefreshedEvent"); + } + + @EventListener + void started(ApplicationStartedEvent event) { + Trace.add("ApplicationStartedEvent"); + } + + @EventListener + void availabilityChanged(AvailabilityChangeEvent<?> event) { + Trace.add("AvailabilityChangeEvent -> %s", event.getState()); + } + + @EventListener + void ready(ApplicationReadyEvent event) { + Trace.add("ApplicationReadyEvent (readiness seen from inside: %s)", availability.getReadinessState()); + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/order/LifecycleProbe.java b/startup-runners/src/main/java/com/ankurm/runners/order/LifecycleProbe.java new file mode 100644 index 0000000..066574a --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/order/LifecycleProbe.java @@ -0,0 +1,49 @@ +package com.ankurm.runners.order; + +import com.ankurm.runners.support.Trace; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.context.SmartLifecycle; +import org.springframework.stereotype.Component; + +/** + * One bean that implements every "too early" hook plus {@link SmartLifecycle}, so the trace shows them in the order + * Spring really calls them. + */ +@Component +class LifecycleProbe implements InitializingBean, SmartInitializingSingleton, SmartLifecycle { + + private boolean running; + + @PostConstruct + void postConstruct() { + Trace.add("@PostConstruct"); + } + + @Override + public void afterPropertiesSet() { + Trace.add("InitializingBean.afterPropertiesSet"); + } + + @Override + public void afterSingletonsInstantiated() { + Trace.add("SmartInitializingSingleton.afterSingletonsInstantiated"); + } + + @Override + public void start() { + running = true; + Trace.add("SmartLifecycle.start (default phase)"); + } + + @Override + public void stop() { + running = false; + } + + @Override + public boolean isRunning() { + return running; + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/order/OrderApp.java b/startup-runners/src/main/java/com/ankurm/runners/order/OrderApp.java new file mode 100644 index 0000000..03d0fc0 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/order/OrderApp.java @@ -0,0 +1,13 @@ +package com.ankurm.runners.order; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** Scenario 1: where do the startup hooks run, relative to each other? Everything writes to {@code Trace}. */ +@SpringBootApplication +public class OrderApp { + + public static void main(String[] args) { + SpringApplication.run(OrderApp.class, args); + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/order/Runners.java b/startup-runners/src/main/java/com/ankurm/runners/order/Runners.java new file mode 100644 index 0000000..28b02f4 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/order/Runners.java @@ -0,0 +1,46 @@ +package com.ankurm.runners.order; + +import com.ankurm.runners.support.Trace; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.availability.ApplicationAvailability; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** The two runner flavours. Explicit {@code @Order} values, because runners without one have no promised order. */ +class Runners { + + @Component + @Order(1) + static class Cli implements CommandLineRunner { + + private final ApplicationAvailability availability; + + Cli(ApplicationAvailability availability) { + this.availability = availability; + } + + @Override + public void run(String... args) { + Trace.add("CommandLineRunner (readiness seen from inside: %s, liveness: %s)", + availability.getReadinessState(), availability.getLivenessState()); + } + } + + @Component + @Order(2) + static class App implements ApplicationRunner { + + private final ApplicationAvailability availability; + + App(ApplicationAvailability availability) { + this.availability = availability; + } + + @Override + public void run(ApplicationArguments args) { + Trace.add("ApplicationRunner (readiness seen from inside: %s)", availability.getReadinessState()); + } + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/ordering/OrderingApp.java b/startup-runners/src/main/java/com/ankurm/runners/ordering/OrderingApp.java new file mode 100644 index 0000000..cbe8774 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/ordering/OrderingApp.java @@ -0,0 +1,92 @@ +package com.ankurm.runners.ordering; + +import com.ankurm.runners.support.Trace; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Scenario 2: seven runners of both kinds, with {@code @Order} on a class, on a {@code @Bean} method and through + * the {@link Ordered} interface, plus one with no order at all. The declaration order below is deliberately + * NOT the execution order. + */ +@SpringBootApplication +public class OrderingApp { + + public static void main(String[] args) { + SpringApplication.run(OrderingApp.class, args); + } + + /** {@code @Order} on a factory method: Spring Boot looks there too, not only on the class. */ + @Bean + @Order(5) + CommandLineRunner cliOrderOnBeanMethod() { + return args -> Trace.add("CommandLineRunner @Order(5) on a @Bean method"); + } + + @Bean + ApplicationRunner arWithNoOrder() { + return args -> Trace.add("ApplicationRunner no order at all (lowest precedence)"); + } + + @Component + @Order(2) + static class CliTwo implements CommandLineRunner { + @Override + public void run(String... args) { + Trace.add("CommandLineRunner @Order(2) on the class"); + } + } + + @Component + @Order(1) + static class ArOne implements ApplicationRunner { + @Override + public void run(ApplicationArguments args) { + Trace.add("ApplicationRunner @Order(1) on the class"); + } + } + + @Component + static class ArThree implements ApplicationRunner, Ordered { + @Override + public int getOrder() { + return 3; + } + + @Override + public void run(ApplicationArguments args) { + Trace.add("ApplicationRunner implements Ordered, getOrder() = 3"); + } + } + + /** Implements both interfaces. Boot calls both methods, one after the other, at this one position. */ + @Component + @Order(4) + static class Both implements CommandLineRunner, ApplicationRunner { + @Override + public void run(ApplicationArguments args) { + Trace.add("Both @Order(4) -> ApplicationRunner.run"); + } + + @Override + public void run(String... args) { + Trace.add("Both @Order(4) -> CommandLineRunner.run"); + } + } + + @Component + @Order(Ordered.HIGHEST_PRECEDENCE) + static class First implements CommandLineRunner { + @Override + public void run(String... args) { + Trace.add("CommandLineRunner @Order(HIGHEST_PRECEDENCE)"); + } + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/slices/ComponentRunner.java b/startup-runners/src/main/java/com/ankurm/runners/slices/ComponentRunner.java new file mode 100644 index 0000000..6daba8d --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/slices/ComponentRunner.java @@ -0,0 +1,16 @@ +package com.ankurm.runners.slices; + +import com.ankurm.runners.support.Trace; +import java.util.Arrays; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +/** A runner found by component scanning, which is what a test slice filters. */ +@Component +class ComponentRunner implements CommandLineRunner { + + @Override + public void run(String... args) { + Trace.add("CommandLineRunner from a top-level @Component ran, args = %s", Arrays.toString(args)); + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/slices/Hello.java b/startup-runners/src/main/java/com/ankurm/runners/slices/Hello.java new file mode 100644 index 0000000..1d9f42a --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/slices/Hello.java @@ -0,0 +1,14 @@ +package com.ankurm.runners.slices; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** The one controller the {@code @WebMvcTest} slice is asked to load. */ +@RestController +public class Hello { + + @GetMapping("/hello") + String hello() { + return "hello"; + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/slices/ReadyListener.java b/startup-runners/src/main/java/com/ankurm/runners/slices/ReadyListener.java new file mode 100644 index 0000000..26c7763 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/slices/ReadyListener.java @@ -0,0 +1,16 @@ +package com.ankurm.runners.slices; + +import com.ankurm.runners.support.Trace; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** An {@code ApplicationReadyEvent} listener found by component scanning. */ +@Component +class ReadyListener { + + @EventListener + void ready(ApplicationReadyEvent e) { + Trace.add("ApplicationReadyEvent listener (a top-level @Component) ran"); + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/slices/SlicesApp.java b/startup-runners/src/main/java/com/ankurm/runners/slices/SlicesApp.java new file mode 100644 index 0000000..06cf358 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/slices/SlicesApp.java @@ -0,0 +1,39 @@ +package com.ankurm.runners.slices; + +import com.ankurm.runners.support.Trace; +import java.util.Arrays; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; + +/** + * Scenario 11: what do tests do with runners? Four startup hooks declared four ways: a runner that is a top-level + * {@code @Component} ({@link ComponentRunner}), an {@code ApplicationReadyEvent} listener that is a top-level + * {@code @Component} ({@link ReadyListener}), a runner that is a {@code @Bean} method on this class, and a runner that is + * a {@code @Component} nested inside this class. + */ +@SpringBootApplication +public class SlicesApp { + + public static void main(String[] args) { + SpringApplication.run(SlicesApp.class, args); + } + + @Bean + ApplicationRunner runnerFromBeanMethod() { + return args -> Trace.add("ApplicationRunner from a @Bean method ran, source args = %s", + Arrays.toString(args.getSourceArgs())); + } + + /** Nested in a configuration class: registered as a member class, so no component-scan filter can exclude it. */ + @Component + static class NestedRunner implements CommandLineRunner { + @Override + public void run(String... args) { + Trace.add("CommandLineRunner nested in SlicesApp ran, args = %s", Arrays.toString(args)); + } + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/support/Trace.java b/startup-runners/src/main/java/com/ankurm/runners/support/Trace.java new file mode 100644 index 0000000..6c8d8d5 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/support/Trace.java @@ -0,0 +1,43 @@ +package com.ankurm.runners.support; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * A tiny in-memory log the scenarios write to and the tests print, so the order of startup steps is data and + * not a guess. When the system property {@code trace.echo} is set (the scenarios that start a second JVM do + * that), every line is also printed to stdout with a {@code TRACE } prefix so the parent test can collect it. + */ +public final class Trace { + + private static final List<String> LINES = new CopyOnWriteArrayList<>(); + private static final boolean ECHO = System.getProperty("trace.echo") != null; + + private Trace() { + } + + public static void add(String format, Object... args) { + String line = args.length == 0 ? format : String.format(format, args); + LINES.add(line); + if (ECHO) { + System.out.println("TRACE " + line); + } + } + + /** Returns everything recorded so far and clears the log. */ + public static List<String> drain() { + var copy = new ArrayList<>(LINES); + LINES.clear(); + return copy; + } + + /** Which kind of thread is running this code, in words that stay the same from run to run. */ + public static String where() { + Thread t = Thread.currentThread(); + if (t.isVirtual()) { + return "a virtual thread"; + } + return t.getName().equals("main") ? "main" : "platform thread " + t.getName(); + } +} diff --git a/startup-runners/src/main/java/com/ankurm/runners/ties/TiesApp.java b/startup-runners/src/main/java/com/ankurm/runners/ties/TiesApp.java new file mode 100644 index 0000000..4786f90 --- /dev/null +++ b/startup-runners/src/main/java/com/ankurm/runners/ties/TiesApp.java @@ -0,0 +1,40 @@ +package com.ankurm.runners.ties; + +import com.ankurm.runners.support.Trace; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.CommandLineRunner; + +/** + * Scenario 3: four runners and no {@code @Order} anywhere. The test starts this in several fresh JVMs and + * records the order each one chose. + */ +@SpringBootApplication +public class TiesApp { + + public static void main(String[] args) { + SpringApplication.run(TiesApp.class, args); + } + + @Bean + CommandLineRunner cliA() { + return args -> Trace.add("cliA"); + } + + @Bean + ApplicationRunner arB() { + return args -> Trace.add("arB"); + } + + @Bean + CommandLineRunner cliC() { + return args -> Trace.add("cliC"); + } + + @Bean + ApplicationRunner arD() { + return args -> Trace.add("arD"); + } +} diff --git a/startup-runners/src/test/java/com/ankurm/runners/BlockingTests.java b/startup-runners/src/test/java/com/ankurm/runners/BlockingTests.java new file mode 100644 index 0000000..14e3723 --- /dev/null +++ b/startup-runners/src/test/java/com/ankurm/runners/BlockingTests.java @@ -0,0 +1,114 @@ +package com.ankurm.runners; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.runners.blocking.BlockingApp; +import com.ankurm.runners.blocking.Gate; +import com.ankurm.runners.support.Trace; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.boot.WebApplicationType; + +/** Sections 7 and 8: a slow runner delays readiness; a runner that hands its work to another thread does not. */ +class BlockingTests { + + private static final HttpClient HTTP = HttpClient.newHttpClient(); + + private static int freePort() throws IOException { + try (var s = new ServerSocket(0)) { + return s.getLocalPort(); + } + } + + private static String get(int port, String path) throws Exception { + var r = HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).build(), + HttpResponse.BodyHandlers.ofString()); + return String.format("%-32s -> %d %s", "GET " + path, r.statusCode(), r.body()); + } + + private static Thread startInBackground(AtomicReference<BootRun.Result> result, int port, String profile) { + var thread = new Thread(() -> result.set(BootRun.run(BlockingApp.class, WebApplicationType.SERVLET, + "server.port=" + port, + "spring.profiles.active=" + profile, + "management.endpoint.health.probes.enabled=true", + "management.endpoints.web.exposure.include=health")), "app-main"); + thread.start(); + return thread; + } + + @Test + void aBlockingRunnerKeepsReadinessDownAndSpringApplicationRunOpen() throws Exception { + try (var t = new Transcript("07-blocking-runner-readiness.txt", + "A runner that blocks: the port is open, liveness is UP, readiness is OUT_OF_SERVICE")) { + Trace.drain(); + Gate.reset(); + int port = freePort(); + var result = new AtomicReference<BootRun.Result>(); + var app = startInBackground(result, port, "blocking"); + assertThat(Gate.RUNNER_STARTED.await(60, TimeUnit.SECONDS)).isTrue(); + + t.section("while the runner is blocked"); + t.line("has SpringApplication.run() returned? %s", result.get() != null); + String ping = get(port, "/ping"); + String live = get(port, "/actuator/health/liveness"); + String ready = get(port, "/actuator/health/readiness"); + t.line(ping); + t.line(live); + t.line(ready); + assertThat(ping).contains("200 pong"); + assertThat(live).contains("200"); + assertThat(ready).contains("503"); + + Gate.RELEASE.countDown(); + app.join(30_000); + + t.section("after the runner returned"); + t.line("has SpringApplication.run() returned? %s", result.get() != null); + String readyAfter = get(port, "/actuator/health/readiness"); + t.line(readyAfter); + assertThat(readyAfter).contains("200"); + t.section("trace"); + Trace.drain().forEach(t::line); + result.get().close(); + } + } + + @Test + void aRunnerThatHandsOffItsWorkLetsTheApplicationBecomeReadyAtOnce() throws Exception { + try (var t = new Transcript("08-async-runner.txt", + "A runner that starts a virtual thread and returns: readiness does not wait, and failures do not fail startup")) { + Trace.drain(); + Gate.reset(); + int port = freePort(); + var result = new AtomicReference<BootRun.Result>(); + var app = startInBackground(result, port, "async"); + app.join(60_000); + assertThat(result.get()).isNotNull(); + assertThat(result.get().started()).isTrue(); + + t.section("the background work is still waiting"); + t.line("has SpringApplication.run() returned? %s", true); + String ready = get(port, "/actuator/health/readiness"); + t.line(ready); + assertThat(ready).contains("200"); + + Gate.RELEASE.countDown(); + assertThat(Gate.BACKGROUND_DONE.await(30, TimeUnit.SECONDS)).isTrue(); + t.section("after the background work threw"); + String readyAfter = get(port, "/actuator/health/readiness"); + t.line(readyAfter); + t.line(get(port, "/ping")); + assertThat(readyAfter).contains("200"); + t.section("trace"); + Trace.drain().forEach(t::line); + result.get().close(); + } + } +} diff --git a/startup-runners/src/test/java/com/ankurm/runners/BootRun.java b/startup-runners/src/test/java/com/ankurm/runners/BootRun.java new file mode 100644 index 0000000..f6ba333 --- /dev/null +++ b/startup-runners/src/test/java/com/ankurm/runners/BootRun.java @@ -0,0 +1,56 @@ +package com.ankurm.runners; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; + +/** Starts a Spring Boot application in this JVM and returns either the context or the failure. */ +public final class BootRun { + + private BootRun() { + } + + public static Result runWithArgs(Class<?> source, WebApplicationType type, String[] args, String... properties) { + var props = new ArrayList<String>(); + props.add("spring.main.banner-mode=off"); + props.add("logging.level.root=OFF"); + props.addAll(List.of(properties)); + try { + ConfigurableApplicationContext ctx = new SpringApplicationBuilder(source) + .web(type) + .properties(props.toArray(String[]::new)) + .run(args); + return new Result(ctx, null); + } catch (RuntimeException e) { + return new Result(null, e); + } + } + + public static Result run(Class<?> source, WebApplicationType type, String... properties) { + return runWithArgs(source, type, new String[0], properties); + } + + /** Names the exception classes from the outside in, with the message of each one. */ + public static List<String> chain(Throwable t) { + var lines = new ArrayList<String>(); + for (Throwable c = t; c != null; c = (c.getCause() == c) ? null : c.getCause()) { + lines.add(c.getClass().getName() + ": " + c.getMessage()); + } + return lines; + } + + public record Result(ConfigurableApplicationContext context, RuntimeException failure) { + + public boolean started() { + return failure == null; + } + + public void close() { + if (context != null) { + context.close(); + } + } + } +} diff --git a/startup-runners/src/test/java/com/ankurm/runners/FailureTests.java b/startup-runners/src/test/java/com/ankurm/runners/FailureTests.java new file mode 100644 index 0000000..e8622b9 --- /dev/null +++ b/startup-runners/src/test/java/com/ankurm/runners/FailureTests.java @@ -0,0 +1,86 @@ +package com.ankurm.runners; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.runners.failure.FailureApp; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Sections 5 and 6: what happens when a runner throws, and when an ApplicationReadyEvent listener throws. */ +class FailureTests { + + private static int freePort() throws IOException { + try (var s = new ServerSocket(0)) { + return s.getLocalPort(); + } + } + + private static boolean portAnswers(int port) { + try (var s = new Socket()) { + s.connect(new InetSocketAddress("localhost", port), 500); + return true; + } catch (IOException e) { + return false; + } + } + + @Test + void aRunnerThatThrowsStopsTheApplication() { + try (var t = new Transcript("05-runner-failure.txt", + "A runner throws: exit code, log, events, and the runners that never ran")) { + t.section("three ordered runners, the second throws (non-web, in a separate JVM)"); + var r = Forked.run(FailureApp.class, + List.of("--spring.profiles.active=runner-fails", "--spring.main.web-application-type=none")); + r.trace().forEach(l -> t.line("TRACE %s", l)); + r.errorLog().forEach(t::line); + t.line("process exit code: %d", r.exitCode()); + assertThat(r.exitCode()).isEqualTo(1); + assertThat(r.trace()).contains("runner 1 ran", "runner 2 is about to throw"); + assertThat(r.trace()).doesNotContain("runner 3 ran"); + assertThat(r.trace().stream().filter(l -> l.startsWith("ApplicationReadyEvent"))).isEmpty(); + + t.section("a runner exception that implements ExitCodeGenerator"); + var e = Forked.run(FailureApp.class, + List.of("--spring.profiles.active=exit-code", "--spring.main.web-application-type=none")); + e.errorLog().forEach(t::line); + t.line("process exit code: %d", e.exitCode()); + assertThat(e.exitCode()).isEqualTo(42); + + t.section("a runner that throws a checked exception (declared with throws Exception)"); + var c = Forked.run(FailureApp.class, + List.of("--spring.profiles.active=checked", "--spring.main.web-application-type=none")); + c.errorLog().forEach(t::line); + t.line("process exit code: %d", c.exitCode()); + assertThat(c.exitCode()).isEqualTo(1); + } + } + + @Test + void aReadyListenerThatThrowsAlsoStopsTheApplication() throws Exception { + try (var t = new Transcript("06-ready-listener-failure.txt", + "An ApplicationReadyEvent listener throws: the app was 'ready' and is now gone")) { + t.section("non-web application"); + var r = Forked.run(FailureApp.class, + List.of("--spring.profiles.active=ready-fails", "--spring.main.web-application-type=none")); + r.trace().forEach(l -> t.line("TRACE %s", l)); + r.errorLog().forEach(t::line); + t.line("process exit code: %d", r.exitCode()); + assertThat(r.exitCode()).isEqualTo(1); + + t.section("web application (Tomcat is up when the listener runs)"); + int port = freePort(); + var w = Forked.run(FailureApp.class, + List.of("--spring.profiles.active=ready-fails", "--server.port=" + port)); + w.trace().forEach(l -> t.line("TRACE %s", l)); + w.errorLog().forEach(t::line); + t.line("process exit code: %d", w.exitCode()); + t.line("does the port answer after the process ended? %s", portAnswers(port)); + assertThat(w.exitCode()).isEqualTo(1); + assertThat(portAnswers(port)).isFalse(); + } + } +} diff --git a/startup-runners/src/test/java/com/ankurm/runners/Forked.java b/startup-runners/src/test/java/com/ankurm/runners/Forked.java new file mode 100644 index 0000000..2782acb --- /dev/null +++ b/startup-runners/src/test/java/com/ankurm/runners/Forked.java @@ -0,0 +1,78 @@ +package com.ankurm.runners; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Runs an application's {@code main} in a second JVM with the test classpath, so the test can see what a real + * {@code java -cp ... Main} does: the exit code and everything written to the console. + */ +public final class Forked { + + private Forked() { + } + + public record Result(int exitCode, List<String> output) { + + /** The lines the scenario wrote with {@code Trace.add}, without the {@code TRACE } prefix. */ + public List<String> trace() { + return output.stream().filter(l -> l.startsWith("TRACE ")).map(l -> l.substring(6)).toList(); + } + + /** + * The log lines worth quoting: the ERROR line and the exception headers ("java.lang.X: message" and + * "Caused by: ..."), without stack frames. The pattern is fixed in {@link #LOG_PATTERN}, so there is no + * timestamp or pid in them. + */ + public List<String> errorLog() { + return output.stream() + .filter(l -> l.startsWith("ERROR ") || l.matches("(Caused by: )?[\\w.$]+(Exception|Error)(: .*)?")) + .toList(); + } + } + + /** A log line without timestamp, pid or thread id, so a transcript does not change from run to run. */ + public static final String LOG_PATTERN = "%-5level %logger{24} : %msg%n%wEx"; + + public static Result run(Class<?> main, List<String> args) { + return run(main, List.of(), args); + } + + /** {@code jvmOptions} go before the main class (for example {@code -Dspring.main.web-application-type=none}). */ + public static Result run(Class<?> main, List<String> jvmOptions, List<String> args) { + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + var cmd = new ArrayList<String>(); + cmd.add(java); + cmd.add("-Dtrace.echo=true"); + cmd.add("-Dspring.main.banner-mode=off"); + cmd.add("-Dlogging.pattern.console=" + LOG_PATTERN); + cmd.addAll(jvmOptions); + cmd.add("-cp"); + cmd.add(System.getProperty("java.class.path")); + cmd.add(main.getName()); + cmd.addAll(args); + try { + Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start(); + var lines = new ArrayList<String>(); + try (var r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + String l; + while ((l = r.readLine()) != null) { + lines.add(l); + } + } + if (!p.waitFor(60, TimeUnit.SECONDS)) { + p.destroyForcibly(); + throw new IllegalStateException("forked JVM did not exit in 60 s: " + lines); + } + return new Result(p.exitValue(), lines); + } catch (IOException | InterruptedException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/startup-runners/src/test/java/com/ankurm/runners/HelloTests.java b/startup-runners/src/test/java/com/ankurm/runners/HelloTests.java new file mode 100644 index 0000000..1ef614f --- /dev/null +++ b/startup-runners/src/test/java/com/ankurm/runners/HelloTests.java @@ -0,0 +1,25 @@ +package com.ankurm.runners; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.runners.hello.HelloApp; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Section 1: the smallest working example, run the way a user would run it: a real JVM and a real command line. */ +class HelloTests { + + @Test + void allThreeHooksPrintOneLineEach() { + try (var t = new Transcript("00-hello.txt", "HelloApp started with: --greeting=hi --verbose notes.txt")) { + var r = Forked.run(HelloApp.class, + List.of("-Dspring.main.web-application-type=none"), List.of("--greeting=hi", "--verbose", "notes.txt")); + var lines = r.output().stream().filter(l -> l.startsWith("hello:")).toList(); + lines.forEach(t::line); + t.line("process exit code: %d", r.exitCode()); + assertThat(r.exitCode()).isZero(); + assertThat(lines).hasSize(3); + assertThat(lines.get(2)).isEqualTo("hello: ApplicationReadyEvent listener"); + } + } +} diff --git a/startup-runners/src/test/java/com/ankurm/runners/LifecycleTests.java b/startup-runners/src/test/java/com/ankurm/runners/LifecycleTests.java new file mode 100644 index 0000000..ea29f3d --- /dev/null +++ b/startup-runners/src/test/java/com/ankurm/runners/LifecycleTests.java @@ -0,0 +1,97 @@ +package com.ankurm.runners; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.runners.lifecycle.LifecycleApp; +import com.ankurm.runners.lifecycle.Svc; +import com.ankurm.runners.lifeforgetful.ForgetfulApp; +import com.ankurm.runners.lifestartfails.StartFailsApp; +import com.ankurm.runners.support.Trace; +import java.util.Comparator; +import org.junit.jupiter.api.Test; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.SpringBootVersion; +import org.springframework.context.SmartLifecycle; +import org.springframework.core.SpringVersion; +import org.springframework.test.util.ReflectionTestUtils; + +/** Sections 9 and 10: SmartLifecycle phases, start, stop, and the two ways it goes wrong. */ +class LifecycleTests { + + @Test + void phasesDecideTheOrderAndStopRunsBackwards() { + try (var t = new Transcript("09-smartlifecycle-phases.txt", + "SmartLifecycle: start in ascending phase, stop in descending phase, runners after all of it")) { + Trace.drain(); + var run = BootRun.run(LifecycleApp.class, WebApplicationType.NONE); + t.section("startup"); + Trace.drain().forEach(t::line); + var manual = run.context().getBean("manual", Svc.class); + t.line("manual.isAutoStartup()=%s isRunning()=%s (never started by the context)", manual.isAutoStartup(), manual.isRunning()); + manual.start(); + t.section("context.close()"); + run.close(); + var stop = Trace.drain(); + stop.forEach(t::line); + assertThat(stop.stream().filter(l -> l.startsWith("stop")).map(l -> l.split("\\s+")[1]).distinct().toList()) + .containsExactly("slow", "high", "manual", "mid", "low"); + assertThat(stop).contains("stop slow finished, calling the callback"); + } + } + + @Test + void theWebServerIsJustTwoMoreLifecycleBeans() { + try (var t = new Transcript("10-lifecycle-beans-in-a-web-app.txt", + "Every SmartLifecycle bean in a servlet web application, by phase (the ones this project adds are marked)")) { + var run = BootRun.run(LifecycleApp.class, WebApplicationType.SERVLET, "server.port=0"); + var ctx = run.context(); + ctx.getBeansOfType(SmartLifecycle.class).entrySet().stream() + .sorted(Comparator.comparingInt(e -> e.getValue().getPhase())) + .forEach(e -> t.line("phase %11d %-30s %s", e.getValue().getPhase(), e.getKey(), + e.getValue() instanceof Svc ? "(this project)" : e.getValue().getClass().getSimpleName())); + t.line("SmartLifecycle.DEFAULT_PHASE = %d", SmartLifecycle.DEFAULT_PHASE); + t.line("Integer.MAX_VALUE = %d", Integer.MAX_VALUE); + t.line("timeout per shutdown phase = %s ms (spring.lifecycle.timeout-per-shutdown-phase, set by Boot)", + ReflectionTestUtils.getField(ctx.getBean("lifecycleProcessor"), "timeoutPerShutdownPhase")); + t.line("Spring Boot %s, Spring Framework %s", SpringBootVersion.getVersion(), SpringVersion.getVersion()); + assertThat(ctx.getBeansOfType(SmartLifecycle.class)).containsKeys("webServerStartStop", "webServerGracefulShutdown"); + run.close(); + Trace.drain(); + } + } + + @Test + void aSmartLifecycleThatThrowsOnStartFailsTheApplicationBeforeAnyRunner() { + try (var t = new Transcript("11-smartlifecycle-start-fails.txt", + "SmartLifecycle.start() throws: no ApplicationStartedEvent, no runner")) { + Trace.drain(); + var run = BootRun.run(StartFailsApp.class, WebApplicationType.NONE); + var lines = Trace.drain(); + lines.forEach(t::line); + t.blank(); + t.line("application started? %s", run.started()); + BootRun.chain(run.failure()).forEach(l -> t.line(" %s", l)); + assertThat(run.started()).isFalse(); + assertThat(lines).doesNotContain("CommandLineRunner runs", "ApplicationStartedEvent"); + } + } + + @Test + void aSmartLifecycleThatNeverReportsRunningIsNeverStopped() { + try (var t = new Transcript("12-smartlifecycle-forgot-isrunning.txt", + "isRunning() always false: start() is called, stop() never is")) { + Trace.drain(); + var run = BootRun.run(ForgetfulApp.class, WebApplicationType.NONE); + t.line("--- startup ---"); + Trace.drain().forEach(t::line); + run.close(); + t.line("--- after context.close() ---"); + var afterClose = Trace.drain(); + afterClose.forEach(t::line); + if (afterClose.isEmpty()) { + t.line("(nothing)"); + } + assertThat(afterClose).isEmpty(); + } + } +} diff --git a/startup-runners/src/test/java/com/ankurm/runners/OrderTests.java b/startup-runners/src/test/java/com/ankurm/runners/OrderTests.java new file mode 100644 index 0000000..ac4f80c --- /dev/null +++ b/startup-runners/src/test/java/com/ankurm/runners/OrderTests.java @@ -0,0 +1,46 @@ +package com.ankurm.runners; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.runners.order.OrderApp; +import com.ankurm.runners.support.Trace; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.WebApplicationType; + +/** Section 2: where each startup hook runs, printed from a real run. */ +class OrderTests { + + @Test + void theHooksRunInOneFixedSequence() { + try (var t = new Transcript("01-startup-hooks-in-order.txt", + "Every startup hook in the order Spring Boot 4.1.2 really calls it")) { + t.section("non-web application"); + Trace.drain(); + var plain = BootRun.run(OrderApp.class, WebApplicationType.NONE); + List<String> noWeb = Trace.drain(); + noWeb.forEach(t::line); + plain.close(); + + t.section("servlet web application (Tomcat on a random port)"); + var web = BootRun.run(OrderApp.class, WebApplicationType.SERVLET, "server.port=0"); + List<String> withWeb = Trace.drain(); + withWeb.forEach(t::line); + web.close(); + + assertThat(noWeb).containsExactly( + "@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"); + assertThat(withWeb).contains("WebServerInitializedEvent (port is open)"); + } + } +} diff --git a/startup-runners/src/test/java/com/ankurm/runners/RunnerTests.java b/startup-runners/src/test/java/com/ankurm/runners/RunnerTests.java new file mode 100644 index 0000000..81a64da --- /dev/null +++ b/startup-runners/src/test/java/com/ankurm/runners/RunnerTests.java @@ -0,0 +1,79 @@ +package com.ankurm.runners; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.runners.args.ArgsApp; +import com.ankurm.runners.ordering.OrderingApp; +import com.ankurm.runners.support.Trace; +import com.ankurm.runners.ties.TiesApp; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.WebApplicationType; + +/** Sections 3 and 4: ordering between runners, and the arguments each flavour receives. */ +class RunnerTests { + + @Test + void orderAnnotationsSortBothFlavoursIntoOneList() { + try (var t = new Transcript("02-order-annotation-and-ordered.txt", + "CommandLineRunner and ApplicationRunner beans sorted together by @Order / Ordered")) { + Trace.drain(); + var run = BootRun.run(OrderingApp.class, WebApplicationType.NONE); + var lines = Trace.drain(); + run.close(); + t.line("(declaration order in OrderingApp.java is NOT the order below)"); + t.blank(); + lines.forEach(t::line); + assertThat(lines).containsExactly( + "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)"); + } + } + + @Test + void runnersWithTheSameOrderHaveNoPromisedSequence() { + // Every run is a fresh JVM, because the sequence comes from identity hash codes, which change per JVM. + try (var t = new Transcript("03-ties-have-no-promised-order.txt", + "Four runners without @Order: the order chosen by each of 8 fresh JVMs")) { + Map<String, Integer> seen = new LinkedHashMap<>(); + for (int i = 1; i <= 8; i++) { + var result = Forked.run(TiesApp.class, List.of("--spring.main.web-application-type=none")); + assertThat(result.exitCode()).isZero(); + String order = String.join(" ", result.trace()); + t.line("JVM %d: %s", i, order); + seen.merge(order, 1, Integer::sum); + } + t.blank(); + t.line("distinct sequences seen: %d", seen.size()); + seen.forEach((order, n) -> t.line(" %-22s x%d", order, n)); + // The contract is only "all four ran". How many distinct sequences appear is exactly what is not promised. + seen.keySet().forEach(order -> assertThat(order.split(" ")).containsExactlyInAnyOrder("cliA", "arB", "cliC", "arD")); + } + } + + @Test + void theTwoFlavoursSeeTheSameCommandLineDifferently() { + try (var t = new Transcript("04-arguments.txt", "The same command line, seen by CommandLineRunner and ApplicationRunner")) { + String[] args = {"--name=Ankur", "--verbose", "--name=Second", "data.csv", "-x", "--", "--after-the-dashes"}; + t.line("args passed to SpringApplication.run: %s", java.util.Arrays.toString(args)); + t.blank(); + Trace.drain(); + var run = BootRun.runWithArgs(ArgsApp.class, WebApplicationType.NONE, args); + var lines = Trace.drain(); + var name = run.context().getEnvironment().getProperty("name"); + run.close(); + lines.forEach(t::line); + t.blank(); + t.line("Environment property 'name' (both values, joined by a comma): %s", name); + assertThat(lines).hasSize(8); + } + } +} diff --git a/startup-runners/src/test/java/com/ankurm/runners/SpringBootTestHooksTests.java b/startup-runners/src/test/java/com/ankurm/runners/SpringBootTestHooksTests.java new file mode 100644 index 0000000..ec79479 --- /dev/null +++ b/startup-runners/src/test/java/com/ankurm/runners/SpringBootTestHooksTests.java @@ -0,0 +1,30 @@ +package com.ankurm.runners; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.runners.slices.SlicesApp; +import com.ankurm.runners.support.Trace; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +/** Section 11: {@code @SpringBootTest} starts the application through SpringApplication, so every startup hook runs. */ +@SpringBootTest(classes = SlicesApp.class, args = "--from-the-test=1") +class SpringBootTestHooksTests { + + @BeforeAll + static void clear() { + Trace.drain(); + } + + @Test + void springBootTestRunsRunnersAndTheReadyEvent() { + List<String> lines = Trace.drain(); + try (var t = new Transcript("13-spring-boot-test-runs-the-hooks.txt", + "@SpringBootTest(classes = SlicesApp.class, args = \"--from-the-test=1\"): what ran before the first test method")) { + lines.forEach(t::line); + } + assertThat(lines).hasSize(4); + } +} diff --git a/startup-runners/src/test/java/com/ankurm/runners/Transcript.java b/startup-runners/src/test/java/com/ankurm/runners/Transcript.java new file mode 100644 index 0000000..715f62b --- /dev/null +++ b/startup-runners/src/test/java/com/ankurm/runners/Transcript.java @@ -0,0 +1,54 @@ +package com.ankurm.runners; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +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. + */ +public final class Transcript implements AutoCloseable { + + private final Path path; + private final StringWriter buffer = new StringWriter(); + private final PrintWriter out = new PrintWriter(buffer); + + public Transcript(String fileName, String title) { + this.path = Path.of("output", fileName); + out.println("# " + title); + out.println(); + } + + public Transcript line(String format, Object... args) { + out.println(args.length == 0 ? format : String.format(format, args)); + return this; + } + + public Transcript blank() { + out.println(); + return this; + } + + public Transcript section(String heading) { + out.println(); + out.println("--- " + heading + " ---"); + return this; + } + + @Override + public void close() { + out.flush(); + // Absolute paths of whoever ran the build are environment noise, not a finding. + String text = buffer.toString().replace(System.getProperty("user.dir"), "<startup-runners>"); + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, text); + } catch (IOException e) { + throw new IllegalStateException("could not write " + path, e); + } + System.out.print(text); + } +} diff --git a/startup-runners/src/test/java/com/ankurm/runners/WebMvcSliceTests.java b/startup-runners/src/test/java/com/ankurm/runners/WebMvcSliceTests.java new file mode 100644 index 0000000..bbccadb --- /dev/null +++ b/startup-runners/src/test/java/com/ankurm/runners/WebMvcSliceTests.java @@ -0,0 +1,36 @@ +package com.ankurm.runners; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.runners.slices.Hello; +import com.ankurm.runners.slices.SlicesApp; +import com.ankurm.runners.support.Trace; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.ContextConfiguration; + +/** Section 11: {@code @WebMvcTest} keeps the {@code @Bean} method (it is on the configuration class) and drops the rest. */ +@WebMvcTest(controllers = Hello.class) +@ContextConfiguration(classes = SlicesApp.class) +class WebMvcSliceTests { + + @BeforeAll + static void clear() { + Trace.drain(); + } + + @Test + void aWebMvcSliceDropsScannedComponentsButKeepsBeanMethodsAndNestedClasses() { + List<String> lines = Trace.drain(); + try (var t = new Transcript("14-webmvctest-slice-hooks.txt", + "@WebMvcTest(controllers = Hello.class): what ran before the first test method")) { + lines.forEach(t::line); + if (lines.isEmpty()) { + t.line("(nothing)"); + } + } + assertThat(lines).hasSize(2); + } +}