From 9825efa081a136182d8b64837961ce2848f22327 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 04:59:40 +0000 Subject: [PATCH] Add core-di: constructor vs setter vs field injection and @Autowired candidate resolution on Boot 4.1 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JoVmf2fWvcpoXndcDSwRf7 --- README.md | 5 + core-di/.gitignore | 2 + core-di/README.md | 86 ++++++ .../01-three-styles-under-plain-new.txt | 18 ++ core-di/output/02-fields-and-finality.txt | 8 + core-di/output/03-injection-order.txt | 5 + .../output/04-field-used-in-constructor.txt | 6 + core-di/output/05-multiple-constructors.txt | 14 + core-di/output/06-optional-setter.txt | 4 + core-di/output/07-spring-wires-all-three.txt | 5 + core-di/output/08-circular-plain-vs-boot.txt | 20 ++ .../09-lazy-breaks-constructor-cycle.txt | 6 + core-di/output/10-no-unique-bean.txt | 8 + core-di/output/11-resolution-ladder.txt | 11 + core-di/output/12-missing-bean.txt | 7 + .../output/13-optional-and-objectprovider.txt | 19 ++ core-di/output/14-list-map-set-injection.txt | 5 + core-di/output/15-empty-collection.txt | 16 ++ core-di/output/16-generics-and-resource.txt | 5 + core-di/output/17-dependency-graph.txt | 6 + core-di/output/18-parameters-flag.txt | 5 + .../19-boot-failure-analysis-ctor-cycle.txt | 23 ++ .../20-boot-failure-analysis-field-cycle.txt | 23 ++ .../21-boot-allow-circular-references.txt | 3 + core-di/output/22-boot-lazy-cycle.txt | 3 + ...-determine-autowire-candidate-bytecode.txt | 28 ++ core-di/output/24-early-reference-caches.txt | 12 + .../output/25-spring-nullable-deprecation.txt | 10 + .../output/26-boot-parent-parameters-flag.txt | 7 + core-di/pom.xml | 44 ++++ core-di/scripts/capture-bytecode.sh | 54 ++++ core-di/scripts/capture-failure-analysis.sh | 42 +++ core-di/scripts/run-all.sh | 25 ++ .../java/com/ankurm/coredi/DiApplication.java | 20 ++ .../com/ankurm/coredi/boot/BootCycles.java | 55 ++++ .../coredi/injection/AllThreeStyles.java | 37 +++ .../ankurm/coredi/injection/BaseBeans.java | 19 ++ .../injection/ConstructorOrderService.java | 23 ++ .../com/ankurm/coredi/injection/CtorA.java | 6 + .../com/ankurm/coredi/injection/CtorB.java | 6 + .../com/ankurm/coredi/injection/FieldA.java | 8 + .../com/ankurm/coredi/injection/FieldB.java | 8 + .../coredi/injection/FieldOrderService.java | 20 ++ .../coredi/injection/FieldTrapService.java | 20 ++ .../com/ankurm/coredi/injection/LazyA.java | 13 + .../com/ankurm/coredi/injection/LazyB.java | 14 + .../injection/LoggingPaymentGateway.java | 21 ++ .../com/ankurm/coredi/injection/Notifier.java | 10 + .../coredi/injection/OptionalAudit.java | 18 ++ .../ankurm/coredi/injection/OrderService.java | 7 + .../coredi/injection/PaymentGateway.java | 10 + .../coredi/injection/RecordingNotifier.java | 19 ++ .../coredi/injection/SetterOrderService.java | 27 ++ .../com/ankurm/coredi/injection/Trace.java | 27 ++ .../injection/TwoConstructorsAnnotated.java | 20 ++ .../injection/TwoConstructorsNoDefault.java | 11 + .../injection/TwoConstructorsService.java | 20 ++ .../ankurm/coredi/resolution/Broadcaster.java | 21 ++ .../coredi/resolution/CheckoutFast.java | 16 ++ .../coredi/resolution/CheckoutNamed.java | 17 ++ .../coredi/resolution/CheckoutPlain.java | 17 ++ .../coredi/resolution/CheckoutQualified.java | 17 ++ .../coredi/resolution/EmailNotifier.java | 8 + .../coredi/resolution/FallbackGateway.java | 8 + .../com/ankurm/coredi/resolution/Fast.java | 15 ++ .../ankurm/coredi/resolution/FastGateway.java | 5 + .../coredi/resolution/GenericConsumer.java | 15 ++ .../com/ankurm/coredi/resolution/Handler.java | 6 + .../coredi/resolution/IntegerHandler.java | 9 + .../coredi/resolution/OptionalConsumers.java | 53 ++++ .../coredi/resolution/PaypalGateway.java | 6 + .../com/ankurm/coredi/resolution/Plugin.java | 4 + .../ankurm/coredi/resolution/PluginHosts.java | 82 ++++++ .../resolution/PrimaryStripeGateway.java | 7 + .../coredi/resolution/PriorityOneGateway.java | 7 + .../coredi/resolution/PriorityTwoGateway.java | 7 + .../coredi/resolution/PushNotifier.java | 7 + .../coredi/resolution/ResourceConsumer.java | 19 ++ .../ankurm/coredi/resolution/SmsNotifier.java | 8 + .../coredi/resolution/StringHandler.java | 9 + .../coredi/resolution/StripeGateway.java | 6 + core-di/src/main/resources/application.yml | 5 + .../coredi/CandidateResolutionTest.java | 247 ++++++++++++++++++ .../ankurm/coredi/CircularDependencyTest.java | 86 ++++++ .../src/test/java/com/ankurm/coredi/Ctx.java | 61 +++++ .../ankurm/coredi/InjectionStylesTest.java | 151 +++++++++++ .../java/com/ankurm/coredi/Transcript.java | 52 ++++ 87 files changed, 1945 insertions(+) create mode 100644 core-di/.gitignore create mode 100644 core-di/README.md create mode 100644 core-di/output/01-three-styles-under-plain-new.txt create mode 100644 core-di/output/02-fields-and-finality.txt create mode 100644 core-di/output/03-injection-order.txt create mode 100644 core-di/output/04-field-used-in-constructor.txt create mode 100644 core-di/output/05-multiple-constructors.txt create mode 100644 core-di/output/06-optional-setter.txt create mode 100644 core-di/output/07-spring-wires-all-three.txt create mode 100644 core-di/output/08-circular-plain-vs-boot.txt create mode 100644 core-di/output/09-lazy-breaks-constructor-cycle.txt create mode 100644 core-di/output/10-no-unique-bean.txt create mode 100644 core-di/output/11-resolution-ladder.txt create mode 100644 core-di/output/12-missing-bean.txt create mode 100644 core-di/output/13-optional-and-objectprovider.txt create mode 100644 core-di/output/14-list-map-set-injection.txt create mode 100644 core-di/output/15-empty-collection.txt create mode 100644 core-di/output/16-generics-and-resource.txt create mode 100644 core-di/output/17-dependency-graph.txt create mode 100644 core-di/output/18-parameters-flag.txt create mode 100644 core-di/output/19-boot-failure-analysis-ctor-cycle.txt create mode 100644 core-di/output/20-boot-failure-analysis-field-cycle.txt create mode 100644 core-di/output/21-boot-allow-circular-references.txt create mode 100644 core-di/output/22-boot-lazy-cycle.txt create mode 100644 core-di/output/23-determine-autowire-candidate-bytecode.txt create mode 100644 core-di/output/24-early-reference-caches.txt create mode 100644 core-di/output/25-spring-nullable-deprecation.txt create mode 100644 core-di/output/26-boot-parent-parameters-flag.txt create mode 100644 core-di/pom.xml create mode 100755 core-di/scripts/capture-bytecode.sh create mode 100755 core-di/scripts/capture-failure-analysis.sh create mode 100755 core-di/scripts/run-all.sh create mode 100644 core-di/src/main/java/com/ankurm/coredi/DiApplication.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/boot/BootCycles.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/AllThreeStyles.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/BaseBeans.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/ConstructorOrderService.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/CtorA.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/CtorB.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/FieldA.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/FieldB.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/FieldOrderService.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/FieldTrapService.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/LazyA.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/LazyB.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/LoggingPaymentGateway.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/Notifier.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/OptionalAudit.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/OrderService.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/PaymentGateway.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/RecordingNotifier.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/SetterOrderService.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/Trace.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsAnnotated.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsNoDefault.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsService.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/Broadcaster.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutFast.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutNamed.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutPlain.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutQualified.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/EmailNotifier.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/FallbackGateway.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/Fast.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/FastGateway.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/GenericConsumer.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/Handler.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/IntegerHandler.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/OptionalConsumers.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/PaypalGateway.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/Plugin.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/PluginHosts.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/PrimaryStripeGateway.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/PriorityOneGateway.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/PriorityTwoGateway.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/PushNotifier.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/ResourceConsumer.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/SmsNotifier.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/StringHandler.java create mode 100644 core-di/src/main/java/com/ankurm/coredi/resolution/StripeGateway.java create mode 100644 core-di/src/main/resources/application.yml create mode 100644 core-di/src/test/java/com/ankurm/coredi/CandidateResolutionTest.java create mode 100644 core-di/src/test/java/com/ankurm/coredi/CircularDependencyTest.java create mode 100644 core-di/src/test/java/com/ankurm/coredi/Ctx.java create mode 100644 core-di/src/test/java/com/ankurm/coredi/InjectionStylesTest.java create mode 100644 core-di/src/test/java/com/ankurm/coredi/Transcript.java diff --git a/README.md b/README.md index ab8d980..82486f5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ files. | [`custom-validation/`](custom-validation) | [Custom Validation in Spring Boot: Beyond the Basics!](https://ankurm.com/custom-validation-in-spring-boot-beyond-the-basics/) | the `javax.validation` to `jakarta.validation` namespace fix Boot 3 already required, Jakarta Validation 3.1's record-validation clarification proven on both field- and class-level custom constraints, a record validation failure's empty 400 body by default, and what `spring.mvc.problemdetails.enabled` does and does not fix | | [`etag-caching/`](etag-caching) | [Mastering Cache Control with ETag in Spring Boot RESTful APIs](https://ankurm.com/etag-cache-control-rest-api-spring-boot/) | `spring-boot-starter-web`'s own POM now reading "deprecated in favor of spring-boot-starter-webmvc", `ShallowEtagHeaderFilter` and `WebRequest.checkNotModified()` re-verified unchanged on Spring Framework 7, deep cache vs shallow cache, and conditional `PUT` with `If-Match` as optimistic locking | | [`restclient-basic-auth/`](restclient-basic-auth) | [Spring Boot RestTemplate with Basic Auth: A Modern Guide](https://ankurm.com/spring-boot-resttemplate-with-basic-auth-a-modern-guide/) | RestClient with Basic Auth two ways against a real embedded server, `{noop}` passwords confirmed to emit no runtime warning at all, `spring-boot-starter-restclient` as its own required Boot 4 module, and the `RestClient.exchange()` trap covered in depth by the [RestTemplate to RestClient migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/) | +| [`core-di/`](core-di) | [Dependency Injection in Spring Boot 4: Constructor vs Setter vs Field (and Why Field Injection Hurts)](https://ankurm.com/spring-boot-4-dependency-injection-constructor-setter-field/) and [@Autowired Explained: By-Type Resolution, @Qualifier, @Primary, ObjectProvider and List Injection](https://ankurm.com/spring-autowired-qualifier-primary-objectprovider-list-injection/) | one `OrderService` in three injection styles built with plain `new`, a field-injected dependency used in a constructor, circular dependencies in plain Spring vs Boot with the real failure report, `@Lazy` injecting a proxy, and the `@Autowired` resolution ladder read from Spring 7.0.9 bytecode (name match beats `@Priority`), plus the empty-collection trap that only bites field and setter injection | + +`core-di` and `core-beans` are the exception: they have **no `docs/` folder**. Their deeper material lives in +collapsible sections inside the articles themselves, and their captured output sits in a top-level `output/` +directory instead of `docs/output/`. Articles whose text is kept here rather than only on the blog have it under `/post/` — `post.md` for the body and `meta.md` for the title, excerpt and diff --git a/core-di/.gitignore b/core-di/.gitignore new file mode 100644 index 0000000..e97c6ee --- /dev/null +++ b/core-di/.gitignore @@ -0,0 +1,2 @@ +target/ +*.class diff --git a/core-di/README.md b/core-di/README.md new file mode 100644 index 0000000..cc1d38c --- /dev/null +++ b/core-di/README.md @@ -0,0 +1,86 @@ +# core-di + +Companion project for two articles on **[ankurm.com](https://ankurm.com)**. + +| Article | What it demonstrates | +|---|---| +| [Dependency Injection in Spring Boot 4: Constructor vs Setter vs Field (and Why Field Injection Hurts)](https://ankurm.com/spring-boot-4-dependency-injection-constructor-setter-field/) | the same service in three injection styles built without Spring, injection order, a field-injected dependency used in a constructor, two-constructor ambiguity, circular dependencies in plain Spring vs Boot, and what `@Lazy` really injects | +| [@Autowired Explained: By-Type Resolution, @Qualifier, @Primary, ObjectProvider and List Injection](https://ankurm.com/spring-autowired-qualifier-primary-objectprovider-list-injection/) | the real `NoUniqueBeanDefinitionException`, the resolution ladder read from Spring 7.0.9 bytecode, `Optional` vs `@Nullable` vs `ObjectProvider`, `List`/`Map` injection order, and the empty-collection trap | + +Every console block, exception message and count quoted in those articles came out of `output/`, +and every file there is regenerated by one script. Most are written by the test suite, so if a +claim stops being true the build goes red. + +There is deliberately **no `docs/` folder**: the deeper material lives in collapsible "going +deeper" sections inside the articles themselves, next to the paragraph each one extends. + +## Versions + +| | | +|---|---| +| Spring Boot | 4.1.1 | +| Spring Framework | 7.0.9 | +| JDK | 25 (Temurin 25.0.4.1+1) | +| Maven | 3.9 | + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn test # runs the scenarios and rewrites the test-written files in output/ +./scripts/run-all.sh # everything, including the script-captured files +``` + +Reproduce the Boot start-up failure report by hand: + +```bash +mvn -DskipTests package +java -jar target/core-di-1.0.0.jar --spring.profiles.active=ctor-cycle +java -jar target/core-di-1.0.0.jar --spring.profiles.active=field-cycle +java -jar target/core-di-1.0.0.jar --spring.profiles.active=field-cycle --spring.main.allow-circular-references=true +``` + +## Source layout + +| Package | What it holds | +|---|---| +| `injection/` | one `OrderService` written three ways (constructor, setter, field), the trap classes (field used in a constructor, two constructors, circular pairs, `@Lazy`) | +| `resolution/` | two gateways and every way to choose between them (`@Primary`, `@Qualifier`, name, `@Priority`, `@Fallback`, custom qualifier), optional injection, collections, generics, `@Resource` | +| `boot/` | profile-gated cycle beans for the real "APPLICATION FAILED TO START" report | + +## Captured output + +Files 01-18 (tests) and 19-22 (`capture-failure-analysis.sh`), 23-26 (`capture-bytecode.sh`). Timing rows assert coarse thresholds, not exact milliseconds; treat them as indicative. + +| File | What it shows | +|---|---| +| [`01-three-styles-under-plain-new.txt`](output/01-three-styles-under-plain-new.txt) | The same OrderService built with plain 'new', no Spring anywhere | +| [`02-fields-and-finality.txt`](output/02-fields-and-finality.txt) | Which injected fields can be final? (reflection over the three variants) | +| [`03-injection-order.txt`](output/03-injection-order.txt) | The order Spring touches one bean that uses all three styles | +| [`04-field-used-in-constructor.txt`](output/04-field-used-in-constructor.txt) | A field-injected collaborator used in the constructor | +| [`05-multiple-constructors.txt`](output/05-multiple-constructors.txt) | Two constructors: which one does Spring pick? | +| [`06-optional-setter.txt`](output/06-optional-setter.txt) | @Autowired(required = false) on a setter | +| [`07-spring-wires-all-three.txt`](output/07-spring-wires-all-three.txt) | Inside a Spring context all three styles work | +| [`08-circular-plain-vs-boot.txt`](output/08-circular-plain-vs-boot.txt) | Constructor cycle vs field cycle: plain Spring and Spring Boot | +| [`09-lazy-breaks-constructor-cycle.txt`](output/09-lazy-breaks-constructor-cycle.txt) | @Lazy on one constructor parameter breaks a constructor cycle | +| [`10-no-unique-bean.txt`](output/10-no-unique-bean.txt) | Two PaymentGateway beans and a consumer that asks for one | +| [`11-resolution-ladder.txt`](output/11-resolution-ladder.txt) | Who wins when several rules apply at once? (one mini-context per row) | +| [`12-missing-bean.txt`](output/12-missing-bean.txt) | No PaymentGateway bean at all | +| [`13-optional-and-objectprovider.txt`](output/13-optional-and-objectprovider.txt) | Five ways to say 'this may not exist', with zero and with two Notifier beans (Email registered before Sms) | +| [`14-list-map-set-injection.txt`](output/14-list-map-set-injection.txt) | List, Map and Set injection; registered Push, Email, Sms in that order | +| [`15-empty-collection.txt`](output/15-empty-collection.txt) | A List injection point when zero Plugin beans exist | +| [`16-generics-and-resource.txt`](output/16-generics-and-resource.txt) | Generic type arguments and @Resource | +| [`17-dependency-graph.txt`](output/17-dependency-graph.txt) | getDependenciesForBean: who did Spring wire into whom? | +| [`18-parameters-flag.txt`](output/18-parameters-flag.txt) | The parameter-name fallback needs javac -parameters (compiled twice from the same source) | +| [`19-boot-failure-analysis-ctor-cycle.txt`](output/19-boot-failure-analysis-ctor-cycle.txt) | Boot start-up failure for a constructor cycle: --spring.profiles.active=ctor-cycle | +| [`20-boot-failure-analysis-field-cycle.txt`](output/20-boot-failure-analysis-field-cycle.txt) | Boot start-up failure for a field-injection cycle: --spring.profiles.active=field-cycle | +| [`21-boot-allow-circular-references.txt`](output/21-boot-allow-circular-references.txt) | Same field cycle with --spring.main.allow-circular-references=true | +| [`22-boot-lazy-cycle.txt`](output/22-boot-lazy-cycle.txt) | @Lazy on one constructor parameter: --spring.profiles.active=lazy-cycle | +| [`23-determine-autowire-candidate-bytecode.txt`](output/23-determine-autowire-candidate-bytecode.txt) | DefaultListableBeanFactory.determineAutowireCandidate, read with javap | +| [`24-early-reference-caches.txt`](output/24-early-reference-caches.txt) | DefaultSingletonBeanRegistry: the fields that hold early references, read with javap | +| [`25-spring-nullable-deprecation.txt`](output/25-spring-nullable-deprecation.txt) | org.springframework.lang.Nullable in spring-core-7.0.9.jar, read with javap -v | +| [`26-boot-parent-parameters-flag.txt`](output/26-boot-parent-parameters-flag.txt) | spring-boot-starter-parent-4.1.1.pom: the compiler flag that parameter-name matching depends on | + +## Licence + +MIT, see the repository root. diff --git a/core-di/output/01-three-styles-under-plain-new.txt b/core-di/output/01-three-styles-under-plain-new.txt new file mode 100644 index 0000000..ba177b9 --- /dev/null +++ b/core-di/output/01-three-styles-under-plain-new.txt @@ -0,0 +1,18 @@ +# The same OrderService built with plain 'new', no Spring anywhere + + +--- constructor injection --- +new ConstructorOrderService(gateway, notifier).place("A-1", 500) + OK -> charged 500 cents for A-1 + charges=1, notifications=1 + +--- setter injection, one setter forgotten --- +new SetterOrderService(); setter.setGateway(gateway); // setNotifier never called + NullPointerException: Cannot invoke "com.ankurm.coredi.injection.Notifier.send(String)" because "this.notifier" is null + charges=1 <-- the customer was charged before the failure + +--- field injection --- +new FieldOrderService().place("A-3", 500) + NullPointerException: Cannot invoke "com.ankurm.coredi.injection.PaymentGateway.charge(String, int)" because "this.gateway" is null +after ReflectionTestUtils.setField(...) twice: + OK -> charged 500 cents for A-3 diff --git a/core-di/output/02-fields-and-finality.txt b/core-di/output/02-fields-and-finality.txt new file mode 100644 index 0000000..a4e1143 --- /dev/null +++ b/core-di/output/02-fields-and-finality.txt @@ -0,0 +1,8 @@ +# Which injected fields can be final? (reflection over the three variants) + +ConstructorOrderService gateway final=true +ConstructorOrderService notifier final=true +SetterOrderService gateway final=false +SetterOrderService notifier final=false +FieldOrderService gateway final=false +FieldOrderService notifier final=false diff --git a/core-di/output/03-injection-order.txt b/core-di/output/03-injection-order.txt new file mode 100644 index 0000000..cdf063b --- /dev/null +++ b/core-di/output/03-injection-order.txt @@ -0,0 +1,5 @@ +# The order Spring touches one bean that uses all three styles + +constructor : constructor arg=set, field=null, setter=null +setter : setter arg=set, field=set +@PostConstruct: constructor=set, field=set, setter=set diff --git a/core-di/output/04-field-used-in-constructor.txt b/core-di/output/04-field-used-in-constructor.txt new file mode 100644 index 0000000..ef75200 --- /dev/null +++ b/core-di/output/04-field-used-in-constructor.txt @@ -0,0 +1,6 @@ +# A field-injected collaborator used in the constructor + +context started: false +top exception : org.springframework.beans.factory.BeanCreationException +top message : Error creating bean with name 'fieldTrapService': Failed to instantiate [com.ankurm.coredi.injection.FieldTrapService]: Constructor threw exception +root cause : java.lang.NullPointerException: Cannot invoke "com.ankurm.coredi.injection.PaymentGateway.charge(String, int)" because "this.gateway" is null diff --git a/core-di/output/05-multiple-constructors.txt b/core-di/output/05-multiple-constructors.txt new file mode 100644 index 0000000..14bcaea --- /dev/null +++ b/core-di/output/05-multiple-constructors.txt @@ -0,0 +1,14 @@ +# Two constructors: which one does Spring pick? + + +--- TwoConstructorsService: no-arg + one-arg, neither annotated --- +[no-arg constructor used] +gateway injected: false + +--- TwoConstructorsNoDefault: one-arg + two-arg, neither annotated --- +org.springframework.beans.factory.BeanCreationException +Error creating bean with name 'twoConstructorsNoDefault': Failed to instantiate [com.ankurm.coredi.injection.TwoConstructorsNoDefault]: No default constructor found +root cause: java.lang.NoSuchMethodException: com.ankurm.coredi.injection.TwoConstructorsNoDefault.() + +--- TwoConstructorsAnnotated: @Autowired on one constructor --- +[annotated constructor used] diff --git a/core-di/output/06-optional-setter.txt b/core-di/output/06-optional-setter.txt new file mode 100644 index 0000000..f6ceaf4 --- /dev/null +++ b/core-di/output/06-optional-setter.txt @@ -0,0 +1,4 @@ +# @Autowired(required = false) on a setter + +with a Notifier bean : notifier present +without a Notifier bean : no notifier configured, auditing silently diff --git a/core-di/output/07-spring-wires-all-three.txt b/core-di/output/07-spring-wires-all-three.txt new file mode 100644 index 0000000..07e3f6b --- /dev/null +++ b/core-di/output/07-spring-wires-all-three.txt @@ -0,0 +1,5 @@ +# Inside a Spring context all three styles work + +ConstructorOrderService -> charged 700 cents for B-1 +SetterOrderService -> charged 700 cents for B-1 +FieldOrderService -> charged 700 cents for B-1 diff --git a/core-di/output/08-circular-plain-vs-boot.txt b/core-di/output/08-circular-plain-vs-boot.txt new file mode 100644 index 0000000..481ce22 --- /dev/null +++ b/core-di/output/08-circular-plain-vs-boot.txt @@ -0,0 +1,20 @@ +# Constructor cycle vs field cycle: plain Spring and Spring Boot + + +--- plain Spring, constructor cycle (CtorA <-> CtorB) --- +started: false +root cause: org.springframework.beans.factory.BeanCurrentlyInCreationException +Error creating bean with name 'ctorA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? + +--- plain Spring, field cycle (FieldA <-> FieldB) --- +started: true +FieldA.b is FieldB: true + +--- Spring Boot, field cycle, default settings --- +FAILED: BeanCurrentlyInCreationException: Error creating bean with name 'fieldA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? + +--- Spring Boot, field cycle, spring.main.allow-circular-references=true --- +started + +--- Spring Boot, constructor cycle, spring.main.allow-circular-references=true --- +FAILED: BeanCurrentlyInCreationException: Error creating bean with name 'ctorA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? diff --git a/core-di/output/09-lazy-breaks-constructor-cycle.txt b/core-di/output/09-lazy-breaks-constructor-cycle.txt new file mode 100644 index 0000000..36c7590 --- /dev/null +++ b/core-di/output/09-lazy-breaks-constructor-cycle.txt @@ -0,0 +1,6 @@ +# @Lazy on one constructor parameter breaks a constructor cycle + +context started: true +LazyA.b runtime class : com.ankurm.coredi.injection.LazyB$$SpringCGLIB$$0 +LazyA.b.hello() : LazyB reached through LazyA's proxy +LazyA.b is the real LazyB bean: false diff --git a/core-di/output/10-no-unique-bean.txt b/core-di/output/10-no-unique-bean.txt new file mode 100644 index 0000000..9f95bc7 --- /dev/null +++ b/core-di/output/10-no-unique-bean.txt @@ -0,0 +1,8 @@ +# Two PaymentGateway beans and a consumer that asks for one + +started: false +top exception : org.springframework.beans.factory.UnsatisfiedDependencyException +top message : Error creating bean with name 'checkoutPlain': Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type 'com.ankurm.coredi.injection.PaymentGateway' available: expected single matching bean but found 2: stripeGateway,paypalGateway + +root cause : org.springframework.beans.factory.NoUniqueBeanDefinitionException +root message : No qualifying bean of type 'com.ankurm.coredi.injection.PaymentGateway' available: expected single matching bean but found 2: stripeGateway,paypalGateway diff --git a/core-di/output/11-resolution-ladder.txt b/core-di/output/11-resolution-ladder.txt new file mode 100644 index 0000000..eb88255 --- /dev/null +++ b/core-di/output/11-resolution-ladder.txt @@ -0,0 +1,11 @@ +# Who wins when several rules apply at once? (one mini-context per row) + +beans registered / injection point winner +---------------------------------------------------------- -------------------- +@Primary Stripe + Paypal; CheckoutPlain PrimaryStripeGateway +@Primary Stripe + Paypal; @Qualifier("paypalGateway") PaypalGateway +@Priority(1) Stripe + @Priority(2) Paypal; CheckoutPlain PriorityOneGateway +@Priority(1) + Paypal(no priority); param named paypalGateway PaypalGateway +Stripe + Paypal (plain); param named paypalGateway PaypalGateway +Stripe (plain) + @Fallback Paypal; CheckoutPlain StripeGateway +@Fast custom qualifier, FastGateway + Paypal; @Fast param FastGateway diff --git a/core-di/output/12-missing-bean.txt b/core-di/output/12-missing-bean.txt new file mode 100644 index 0000000..5634ac6 --- /dev/null +++ b/core-di/output/12-missing-bean.txt @@ -0,0 +1,7 @@ +# No PaymentGateway bean at all + +top exception : org.springframework.beans.factory.UnsatisfiedDependencyException +top message : Error creating bean with name 'checkoutPlain': Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type 'com.ankurm.coredi.injection.PaymentGateway' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {} + +root cause : org.springframework.beans.factory.NoSuchBeanDefinitionException +root message : No qualifying bean of type 'com.ankurm.coredi.injection.PaymentGateway' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {} diff --git a/core-di/output/13-optional-and-objectprovider.txt b/core-di/output/13-optional-and-objectprovider.txt new file mode 100644 index 0000000..60c7916 --- /dev/null +++ b/core-di/output/13-optional-and-objectprovider.txt @@ -0,0 +1,19 @@ +# Five ways to say 'this may not exist', with zero and with two Notifier beans (Email registered before Sms) + + +--- zero Notifier beans --- +plain constructor param : FAILED NoSuchBeanDefinitionException +Optional : isPresent=false +@Nullable Notifier : value=null +ObjectProvider.getIfAvailable() : null +ObjectProvider.getIfUnique() : null +ObjectProvider.getObject() : NoSuchBeanDefinitionException: No qualifying bean of type 'com.ankurm.coredi.injection.Notifier' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {} +@Autowired(required=false) setter: value=null + +--- two Notifier beans (Sms, Email), no @Primary --- +ObjectProvider.getIfAvailable() : NoUniqueBeanDefinitionException: No qualifying bean of type 'com.ankurm.coredi.injection.Notifier' available: expected single matching bean but found 2: emailNotifier,smsNotifier +ObjectProvider.getIfUnique() : null +ObjectProvider.stream() classes : [EmailNotifier, SmsNotifier] +ObjectProvider.orderedStream() : [SmsNotifier, EmailNotifier] +Optional with two beans: FAILED NoUniqueBeanDefinitionException + No qualifying bean of type 'com.ankurm.coredi.injection.Notifier' available: expected single matching bean but found 2: emailNotifier,smsNotifier diff --git a/core-di/output/14-list-map-set-injection.txt b/core-di/output/14-list-map-set-injection.txt new file mode 100644 index 0000000..54cf47f --- /dev/null +++ b/core-di/output/14-list-map-set-injection.txt @@ -0,0 +1,5 @@ +# List, Map and Set injection; registered Push, Email, Sms in that order + +List order : [SmsNotifier, EmailNotifier, PushNotifier] +Map : [pushNotifier, emailNotifier, smsNotifier] +Set size : 3 diff --git a/core-di/output/15-empty-collection.txt b/core-di/output/15-empty-collection.txt new file mode 100644 index 0000000..0d57cc9 --- /dev/null +++ b/core-di/output/15-empty-collection.txt @@ -0,0 +1,16 @@ +# A List injection point when zero Plugin beans exist + + +--- required List, zero Plugin beans --- +single constructor param : started (empty list injected) +@Autowired ctor + no-arg ctor : started (empty list injected) +FieldInjected : FAILED NoSuchBeanDefinitionException + No qualifying bean of type 'java.util.List' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)} +SetterInjected : FAILED NoSuchBeanDefinitionException + No qualifying bean of type 'java.util.List' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {} + +--- ways to make an absent collection legal --- +Optional> : isPresent=false +@Nullable List : value=null +ObjectProvider -> list : [] +@Autowired(required=false) list: [] diff --git a/core-di/output/16-generics-and-resource.txt b/core-di/output/16-generics-and-resource.txt new file mode 100644 index 0000000..c67a462 --- /dev/null +++ b/core-di/output/16-generics-and-resource.txt @@ -0,0 +1,5 @@ +# Generic type arguments and @Resource + +List> : [StringHandler] +Handler : IntegerHandler +@Resource : paypalGateway field -> PaypalGateway, whateverIWantToCallIt field -> StripeGateway diff --git a/core-di/output/17-dependency-graph.txt b/core-di/output/17-dependency-graph.txt new file mode 100644 index 0000000..4ef4858 --- /dev/null +++ b/core-di/output/17-dependency-graph.txt @@ -0,0 +1,6 @@ +# getDependenciesForBean: who did Spring wire into whom? + +constructorOrderService depends on [paymentGateway, notifier] +setterOrderService depends on [paymentGateway, notifier] +fieldOrderService depends on [paymentGateway, notifier] +paymentGateway is used by [constructorOrderService, setterOrderService, fieldOrderService] diff --git a/core-di/output/18-parameters-flag.txt b/core-di/output/18-parameters-flag.txt new file mode 100644 index 0000000..90ba193 --- /dev/null +++ b/core-di/output/18-parameters-flag.txt @@ -0,0 +1,5 @@ +# The parameter-name fallback needs javac -parameters (compiled twice from the same source) + +javac -parameters : started, picked PaypalGateway +javac (no flag) : FAILED NoUniqueBeanDefinitionException + No qualifying bean of type 'com.ankurm.coredi.injection.PaymentGateway' available: expected single matching bean but found 2: stripeGateway,paypalGateway diff --git a/core-di/output/19-boot-failure-analysis-ctor-cycle.txt b/core-di/output/19-boot-failure-analysis-ctor-cycle.txt new file mode 100644 index 0000000..54a2565 --- /dev/null +++ b/core-di/output/19-boot-failure-analysis-ctor-cycle.txt @@ -0,0 +1,23 @@ +# Boot start-up failure for a constructor cycle: --spring.profiles.active=ctor-cycle + +ERROR --- o.s.b.d.LoggingFailureAnalysisReporter : + +*************************** +APPLICATION FAILED TO START +*************************** + +Description: + +The dependencies of some of the beans in the application context form a cycle: + +┌─────┐ +| bootCycles.CtorOne defined in URL [jar:nested:target/core-di-1.0.0.jar/!BOOT-INF/classes/!/com/ankurm/coredi/boot/BootCycles$CtorOne.class] +↑ ↓ +| bootCycles.CtorTwo defined in URL [jar:nested:target/core-di-1.0.0.jar/!BOOT-INF/classes/!/com/ankurm/coredi/boot/BootCycles$CtorTwo.class] +└─────┘ + + +Action: + +Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true. + diff --git a/core-di/output/20-boot-failure-analysis-field-cycle.txt b/core-di/output/20-boot-failure-analysis-field-cycle.txt new file mode 100644 index 0000000..a7ef9fa --- /dev/null +++ b/core-di/output/20-boot-failure-analysis-field-cycle.txt @@ -0,0 +1,23 @@ +# Boot start-up failure for a field-injection cycle: --spring.profiles.active=field-cycle + +ERROR --- o.s.b.d.LoggingFailureAnalysisReporter : + +*************************** +APPLICATION FAILED TO START +*************************** + +Description: + +The dependencies of some of the beans in the application context form a cycle: + +┌─────┐ +| bootCycles.FieldOne (field com.ankurm.coredi.boot.BootCycles$FieldTwo com.ankurm.coredi.boot.BootCycles$FieldOne.two) +↑ ↓ +| bootCycles.FieldTwo (field com.ankurm.coredi.boot.BootCycles$FieldOne com.ankurm.coredi.boot.BootCycles$FieldTwo.one) +└─────┘ + + +Action: + +Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true. + diff --git a/core-di/output/21-boot-allow-circular-references.txt b/core-di/output/21-boot-allow-circular-references.txt new file mode 100644 index 0000000..8dc7083 --- /dev/null +++ b/core-di/output/21-boot-allow-circular-references.txt @@ -0,0 +1,3 @@ +# Same field cycle with --spring.main.allow-circular-references=true + +STARTED. Beans from the cycle: [bootCycles.FieldOne, bootCycles.FieldTwo] diff --git a/core-di/output/22-boot-lazy-cycle.txt b/core-di/output/22-boot-lazy-cycle.txt new file mode 100644 index 0000000..131d234 --- /dev/null +++ b/core-di/output/22-boot-lazy-cycle.txt @@ -0,0 +1,3 @@ +# @Lazy on one constructor parameter: --spring.profiles.active=lazy-cycle + +STARTED. Beans from the cycle: [bootCycles.LazyOne, bootCycles.LazyTwo] diff --git a/core-di/output/23-determine-autowire-candidate-bytecode.txt b/core-di/output/23-determine-autowire-candidate-bytecode.txt new file mode 100644 index 0000000..ee0b320 --- /dev/null +++ b/core-di/output/23-determine-autowire-candidate-bytecode.txt @@ -0,0 +1,28 @@ +# DefaultListableBeanFactory.determineAutowireCandidate, read with javap +# jar: spring-beans-7.0.9.jar + + protected java.lang.String determineAutowireCandidate(java.util.Map, org.springframework.beans.factory.config.DependencyDescriptor); +invokevirtual #1076 // Method org/springframework/beans/factory/config/DependencyDescriptor.getDependencyType:()Ljava/lang/Class; +invokevirtual #1039 // Method determinePrimaryCandidate:(Ljava/util/Map;Ljava/lang/Class;)Ljava/lang/String; +invokevirtual #1169 // Method org/springframework/beans/factory/config/DependencyDescriptor.getDependencyName:()Ljava/lang/String; +invokeinterface #970, 1 // InterfaceMethod java/util/Map.keySet:()Ljava/util/Set; +invokeinterface #397, 1 // InterfaceMethod java/util/Set.iterator:()Ljava/util/Iterator; +invokeinterface #304, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z +invokeinterface #309, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object; +invokevirtual #1393 // Method matchesBeanName:(Ljava/lang/String;Ljava/lang/String;)Z +invokevirtual #150 // Method getAutowireCandidateResolver:()Lorg/springframework/beans/factory/support/AutowireCandidateResolver; +invokeinterface #1172, 2 // InterfaceMethod org/springframework/beans/factory/support/AutowireCandidateResolver.getSuggestedName:(Lorg/springframework/beans/factory/config/DependencyDescriptor;)Ljava/lang/String; +invokeinterface #970, 1 // InterfaceMethod java/util/Map.keySet:()Ljava/util/Set; +invokeinterface #397, 1 // InterfaceMethod java/util/Set.iterator:()Ljava/util/Iterator; +invokeinterface #304, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z +invokeinterface #309, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object; +invokevirtual #1393 // Method matchesBeanName:(Ljava/lang/String;Ljava/lang/String;)Z +invokevirtual #1043 // Method determineHighestPriorityCandidate:(Ljava/util/Map;Ljava/lang/Class;)Ljava/lang/String; +invokevirtual #1046 // Method determineDefaultCandidate:(Ljava/util/Map;)Ljava/lang/String; +invokeinterface #1230, 1 // InterfaceMethod java/util/Map.entrySet:()Ljava/util/Set; +invokeinterface #397, 1 // InterfaceMethod java/util/Set.iterator:()Ljava/util/Iterator; +invokeinterface #304, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z +invokeinterface #309, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object; +invokeinterface #1235, 1 // InterfaceMethod java/util/Map$Entry.getKey:()Ljava/lang/Object; +invokeinterface #1238, 1 // InterfaceMethod java/util/Map$Entry.getValue:()Ljava/lang/Object; +invokeinterface #1399, 2 // InterfaceMethod java/util/Map.containsValue:(Ljava/lang/Object;)Z diff --git a/core-di/output/24-early-reference-caches.txt b/core-di/output/24-early-reference-caches.txt new file mode 100644 index 0000000..fd859e3 --- /dev/null +++ b/core-di/output/24-early-reference-caches.txt @@ -0,0 +1,12 @@ +# DefaultSingletonBeanRegistry: the fields that hold early references, read with javap +# jar: spring-beans-7.0.9.jar + + private final java.util.Map singletonObjects; + private final java.util.Map> singletonFactories; + private final java.util.Map earlySingletonObjects; + private final java.util.Set singletonsCurrentlyInCreation; + +# DefaultListableBeanFactory / AbstractAutowireCapableBeanFactory: the allow-circular-references switch + private boolean allowCircularReferences; + public void setAllowCircularReferences(boolean); + public boolean isAllowCircularReferences(); diff --git a/core-di/output/25-spring-nullable-deprecation.txt b/core-di/output/25-spring-nullable-deprecation.txt new file mode 100644 index 0000000..88db7fc --- /dev/null +++ b/core-di/output/25-spring-nullable-deprecation.txt @@ -0,0 +1,10 @@ +# org.springframework.lang.Nullable in spring-core-7.0.9.jar, read with javap -v + + #9 = Utf8 Deprecated + #10 = Utf8 RuntimeVisibleAnnotations + #22 = Utf8 Ljava/lang/Deprecated; + #23 = Utf8 since +Deprecated: true +RuntimeVisibleAnnotations: + java.lang.Deprecated( + since="7.0" diff --git a/core-di/output/26-boot-parent-parameters-flag.txt b/core-di/output/26-boot-parent-parameters-flag.txt new file mode 100644 index 0000000..a2f981e --- /dev/null +++ b/core-di/output/26-boot-parent-parameters-flag.txt @@ -0,0 +1,7 @@ +# spring-boot-starter-parent-4.1.1.pom: the compiler flag that parameter-name matching depends on + +111- maven-compiler-plugin +112- +113: true +114- +115- diff --git a/core-di/pom.xml b/core-di/pom.xml new file mode 100644 index 0000000..be03fa9 --- /dev/null +++ b/core-di/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + core-di + 1.0.0 + core-di + Dependency injection in Spring Boot 4: constructor vs setter vs field, and how @Autowired resolves candidates + + + 25 + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/core-di/scripts/capture-bytecode.sh b/core-di/scripts/capture-bytecode.sh new file mode 100755 index 0000000..51f9592 --- /dev/null +++ b/core-di/scripts/capture-bytecode.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Reads the resolution order out of Spring's own bytecode instead of trusting the reference +# documentation's prose: which determine* helpers DefaultListableBeanFactory calls, in which order. +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p output target/bytecode +JAR=$(find ~/.m2/repository/org/springframework/spring-beans -name 'spring-beans-*.jar' ! -name '*sources*' | sort | tail -1) +unzip -o -q "$JAR" 'org/springframework/beans/factory/support/DefaultListableBeanFactory*' -d target/bytecode +{ + echo "# DefaultListableBeanFactory.determineAutowireCandidate, read with javap" + echo "# jar: $(basename "$JAR")" + echo + javap -p -c -cp target/bytecode org.springframework.beans.factory.support.DefaultListableBeanFactory 2>&1 \ + | grep -v 'Picked up' \ + | awk '/protected java.lang.String determineAutowireCandidate\(/{f=1} f{print} /^$/{if(f) exit}' \ + | grep -E 'determineAutowireCandidate|invoke|instanceof' | sed 's/^ *[0-9]*: //' +} > output/23-determine-autowire-candidate-bytecode.txt +cat output/23-determine-autowire-candidate-bytecode.txt + +# --- the early-reference caches behind circular-reference support +JAR2=$(find ~/.m2/repository/org/springframework/spring-beans -name 'spring-beans-*.jar' ! -name '*sources*' | sort | tail -1) +unzip -o -q "$JAR2" 'org/springframework/beans/factory/support/DefaultSingletonBeanRegistry.class' -d target/bytecode +{ + echo "# DefaultSingletonBeanRegistry: the fields that hold early references, read with javap" + echo "# jar: $(basename "$JAR2")" + echo + javap -p -cp target/bytecode org.springframework.beans.factory.support.DefaultSingletonBeanRegistry 2>&1 \ + | grep -v 'Picked up' | grep -E ' (singletonObjects|earlySingletonObjects|singletonFactories|singletonsCurrentlyInCreation|allowCircularReferences)[;]?' || true + echo + echo "# DefaultListableBeanFactory / AbstractAutowireCapableBeanFactory: the allow-circular-references switch" + unzip -o -q "$JAR2" 'org/springframework/beans/factory/support/AbstractAutowireCapableBeanFactory.class' -d target/bytecode + javap -p -cp target/bytecode org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory 2>&1 \ + | grep -v 'Picked up' | grep -i 'allowCircularReferences' || true +} > output/24-early-reference-caches.txt +cat output/24-early-reference-caches.txt + +# --- is the old Spring-specific @Nullable deprecated in 7.0.9? +JAR3=$(find ~/.m2/repository/org/springframework/spring-core -name 'spring-core-7*.jar' ! -name '*sources*' | sort | tail -1) +unzip -o -q "$JAR3" 'org/springframework/lang/Nullable.class' -d target/bytecode +{ + echo "# org.springframework.lang.Nullable in $(basename "$JAR3"), read with javap -v" + echo + javap -v -cp target/bytecode org.springframework.lang.Nullable 2>&1 | grep -v 'Picked up' | grep -E 'Deprecated|RuntimeVisibleAnnotations|Ljava/lang/Deprecated|forRemoval|since' || true +} > output/25-spring-nullable-deprecation.txt +cat output/25-spring-nullable-deprecation.txt + +# --- does the Boot parent compile with -parameters? +PARENT=$(find ~/.m2/repository/org/springframework/boot/spring-boot-starter-parent -name '*.pom' | sort | tail -1) +{ + echo "# $(basename "$PARENT"): the compiler flag that parameter-name matching depends on" + echo + grep -n -B2 -A2 '' "$PARENT" || echo "(no element found)" +} > output/26-boot-parent-parameters-flag.txt +cat output/26-boot-parent-parameters-flag.txt diff --git a/core-di/scripts/capture-failure-analysis.sh b/core-di/scripts/capture-failure-analysis.sh new file mode 100755 index 0000000..a21a399 --- /dev/null +++ b/core-di/scripts/capture-failure-analysis.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Starts the real Boot application with three different profiles and captures what a developer +# actually sees: the "APPLICATION FAILED TO START" report for a circular dependency, and the same +# application starting once spring.main.allow-circular-references is switched on. +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p output +JAR=$(ls target/core-di-*.jar | head -1) + +# Drops JAVA_TOOL_OPTIONS noise, machine-specific paths, timestamps and PIDs so reruns diff cleanly. +strip() { + grep -v -E '^Picked up JAVA_TOOL_OPTIONS' \ + | sed -E 's#jar:nested:[^ ]*/target/#jar:nested:target/#; s#^[0-9T:.+-]+ (ERROR|WARN|INFO) [0-9]+ --- \[core-di\] \[[^]]*\] #\1 --- #' \ + || true +} + +{ + echo "# Boot start-up failure for a constructor cycle: --spring.profiles.active=ctor-cycle" + echo + java -jar "$JAR" --spring.profiles.active=ctor-cycle --spring.main.banner-mode=off --logging.level.root=ERROR 2>&1 | strip || true +} > output/19-boot-failure-analysis-ctor-cycle.txt + +{ + echo "# Boot start-up failure for a field-injection cycle: --spring.profiles.active=field-cycle" + echo + java -jar "$JAR" --spring.profiles.active=field-cycle --spring.main.banner-mode=off --logging.level.root=ERROR 2>&1 | strip || true +} > output/20-boot-failure-analysis-field-cycle.txt + +{ + echo "# Same field cycle with --spring.main.allow-circular-references=true" + echo + java -jar "$JAR" --spring.profiles.active=field-cycle --spring.main.allow-circular-references=true \ + --spring.main.banner-mode=off --logging.level.root=WARN 2>&1 | strip +} > output/21-boot-allow-circular-references.txt + +{ + echo "# @Lazy on one constructor parameter: --spring.profiles.active=lazy-cycle" + echo + java -jar "$JAR" --spring.profiles.active=lazy-cycle --spring.main.banner-mode=off --logging.level.root=WARN 2>&1 | strip +} > output/22-boot-lazy-cycle.txt + +cat output/19-*.txt output/20-*.txt output/21-*.txt output/22-*.txt diff --git a/core-di/scripts/run-all.sh b/core-di/scripts/run-all.sh new file mode 100755 index 0000000..825b318 --- /dev/null +++ b/core-di/scripts/run-all.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Regenerates every file under output/. +# +# ./scripts/run-all.sh +# +# Needs a JDK 25 and Maven 3.9. Transcripts 01-18 come out of the test suite, which is the point: +# the figures in the two articles are assertions that fail the build if they stop being true. +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "== test suite (transcripts 01-18)" +mvn -B test + +echo "== package the runnable Boot app" +mvn -B -q -DskipTests package + +echo "== Boot start-up failure reports (19-22)" +./scripts/capture-failure-analysis.sh + +echo "== bytecode and pom facts (23-26)" +./scripts/capture-bytecode.sh + +echo +echo "output:" +ls -1 output diff --git a/core-di/src/main/java/com/ankurm/coredi/DiApplication.java b/core-di/src/main/java/com/ankurm/coredi/DiApplication.java new file mode 100644 index 0000000..281396b --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/DiApplication.java @@ -0,0 +1,20 @@ +package com.ankurm.coredi; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Only used by scripts/capture-failure-analysis.sh: it starts a real Boot application so the + * "APPLICATION FAILED TO START" report for a circular dependency can be captured verbatim. + * Everything else in this module runs from the tests. + */ +@SpringBootApplication(scanBasePackages = "com.ankurm.coredi.boot") +public class DiApplication { + + public static void main(String[] args) { + var ctx = SpringApplication.run(DiApplication.class, args); + var cycleBeans = java.util.Arrays.stream(ctx.getBeanDefinitionNames()) + .filter(n -> n.startsWith("bootCycles.")).sorted().toList(); + System.out.println("STARTED. Beans from the cycle: " + cycleBeans); + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/boot/BootCycles.java b/core-di/src/main/java/com/ankurm/coredi/boot/BootCycles.java new file mode 100644 index 0000000..a0d505b --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/boot/BootCycles.java @@ -0,0 +1,55 @@ +package com.ankurm.coredi.boot; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** Three ways to start the same application with a cycle in it. Pick with --spring.profiles.active. */ +public final class BootCycles { + + private BootCycles() { + } + + @Component + @Profile("ctor-cycle") + public static class CtorOne { + public CtorOne(CtorTwo two) { + } + } + + @Component + @Profile("ctor-cycle") + public static class CtorTwo { + public CtorTwo(CtorOne one) { + } + } + + @Component + @Profile("field-cycle") + public static class FieldOne { + @Autowired + FieldTwo two; + } + + @Component + @Profile("field-cycle") + public static class FieldTwo { + @Autowired + FieldOne one; + } + + @Component + @Profile("lazy-cycle") + public static class LazyOne { + public LazyOne(@Lazy LazyTwo two) { + } + } + + @Component + @Profile("lazy-cycle") + public static class LazyTwo { + public LazyTwo(LazyOne one) { + } + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/AllThreeStyles.java b/core-di/src/main/java/com/ankurm/coredi/injection/AllThreeStyles.java new file mode 100644 index 0000000..339de12 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/AllThreeStyles.java @@ -0,0 +1,37 @@ +package com.ankurm.coredi.injection; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Autowired; + +/** One bean using all three styles at once, only to make Spring's order of operations visible. */ +public class AllThreeStyles { + + @Autowired + private Notifier fieldDependency; + + private PaymentGateway setterDependency; + + private final Notifier constructorDependency; + + public AllThreeStyles(Notifier constructorDependency) { + this.constructorDependency = constructorDependency; + Trace.log("constructor : constructor arg=" + describe(constructorDependency) + + ", field=" + describe(fieldDependency) + ", setter=" + describe(setterDependency)); + } + + @Autowired + public void setSetterDependency(PaymentGateway gateway) { + this.setterDependency = gateway; + Trace.log("setter : setter arg=" + describe(gateway) + ", field=" + describe(fieldDependency)); + } + + @PostConstruct + void afterInjection() { + Trace.log("@PostConstruct: constructor=" + describe(constructorDependency) + + ", field=" + describe(fieldDependency) + ", setter=" + describe(setterDependency)); + } + + private static String describe(Object o) { + return o == null ? "null" : "set"; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/BaseBeans.java b/core-di/src/main/java/com/ankurm/coredi/injection/BaseBeans.java new file mode 100644 index 0000000..9a4f858 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/BaseBeans.java @@ -0,0 +1,19 @@ +package com.ankurm.coredi.injection; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** The two collaborators, registered once so every service variant can be pointed at them. */ +@Configuration +public class BaseBeans { + + @Bean + PaymentGateway paymentGateway() { + return new LoggingPaymentGateway(); + } + + @Bean + Notifier notifier() { + return new RecordingNotifier(); + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/ConstructorOrderService.java b/core-di/src/main/java/com/ankurm/coredi/injection/ConstructorOrderService.java new file mode 100644 index 0000000..efde057 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/ConstructorOrderService.java @@ -0,0 +1,23 @@ +package com.ankurm.coredi.injection; + +/** + * Constructor injection. One constructor, so {@code @Autowired} is not needed. Both fields are + * {@code final}, and the class cannot be instantiated without its collaborators. + */ +public class ConstructorOrderService implements OrderService { + + private final PaymentGateway gateway; + private final Notifier notifier; + + public ConstructorOrderService(PaymentGateway gateway, Notifier notifier) { + this.gateway = gateway; + this.notifier = notifier; + } + + @Override + public String place(String orderId, int cents) { + String receipt = gateway.charge(orderId, cents); + notifier.send(receipt); + return receipt; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/CtorA.java b/core-di/src/main/java/com/ankurm/coredi/injection/CtorA.java new file mode 100644 index 0000000..45e4f04 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/CtorA.java @@ -0,0 +1,6 @@ +package com.ankurm.coredi.injection; + +public class CtorA { + public CtorA(CtorB b) { + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/CtorB.java b/core-di/src/main/java/com/ankurm/coredi/injection/CtorB.java new file mode 100644 index 0000000..7f67df9 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/CtorB.java @@ -0,0 +1,6 @@ +package com.ankurm.coredi.injection; + +public class CtorB { + public CtorB(CtorA a) { + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/FieldA.java b/core-di/src/main/java/com/ankurm/coredi/injection/FieldA.java new file mode 100644 index 0000000..43dc1f5 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/FieldA.java @@ -0,0 +1,8 @@ +package com.ankurm.coredi.injection; + +import org.springframework.beans.factory.annotation.Autowired; + +public class FieldA { + @Autowired + public FieldB b; +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/FieldB.java b/core-di/src/main/java/com/ankurm/coredi/injection/FieldB.java new file mode 100644 index 0000000..90af236 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/FieldB.java @@ -0,0 +1,8 @@ +package com.ankurm.coredi.injection; + +import org.springframework.beans.factory.annotation.Autowired; + +public class FieldB { + @Autowired + public FieldA a; +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/FieldOrderService.java b/core-di/src/main/java/com/ankurm/coredi/injection/FieldOrderService.java new file mode 100644 index 0000000..aa6b6c7 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/FieldOrderService.java @@ -0,0 +1,20 @@ +package com.ankurm.coredi.injection; + +import org.springframework.beans.factory.annotation.Autowired; + +/** Field injection. Shortest to write; the only way to supply the fields outside Spring is reflection. */ +public class FieldOrderService implements OrderService { + + @Autowired + private PaymentGateway gateway; + + @Autowired + private Notifier notifier; + + @Override + public String place(String orderId, int cents) { + String receipt = gateway.charge(orderId, cents); + notifier.send(receipt); + return receipt; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/FieldTrapService.java b/core-di/src/main/java/com/ankurm/coredi/injection/FieldTrapService.java new file mode 100644 index 0000000..a1df088 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/FieldTrapService.java @@ -0,0 +1,20 @@ +package com.ankurm.coredi.injection; + +import org.springframework.beans.factory.annotation.Autowired; + +/** Uses a field-injected collaborator in its constructor, before Spring has had a chance to set it. */ +public class FieldTrapService { + + @Autowired + private PaymentGateway gateway; + + private final String warmup; + + public FieldTrapService() { + this.warmup = gateway.charge("warm-up", 0); + } + + public String warmup() { + return warmup; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/LazyA.java b/core-di/src/main/java/com/ankurm/coredi/injection/LazyA.java new file mode 100644 index 0000000..9e46ef5 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/LazyA.java @@ -0,0 +1,13 @@ +package com.ankurm.coredi.injection; + +import org.springframework.context.annotation.Lazy; + +/** The cycle is still there; {@code @Lazy} turns one edge into a proxy that resolves on first use. */ +public class LazyA { + + public final LazyB b; + + public LazyA(@Lazy LazyB b) { + this.b = b; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/LazyB.java b/core-di/src/main/java/com/ankurm/coredi/injection/LazyB.java new file mode 100644 index 0000000..01c4afe --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/LazyB.java @@ -0,0 +1,14 @@ +package com.ankurm.coredi.injection; + +public class LazyB { + + final LazyA a; + + public LazyB(LazyA a) { + this.a = a; + } + + public String hello() { + return "LazyB reached through LazyA's proxy"; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/LoggingPaymentGateway.java b/core-di/src/main/java/com/ankurm/coredi/injection/LoggingPaymentGateway.java new file mode 100644 index 0000000..9093ddf --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/LoggingPaymentGateway.java @@ -0,0 +1,21 @@ +package com.ankurm.coredi.injection; + +import java.util.ArrayList; +import java.util.List; + +public class LoggingPaymentGateway implements PaymentGateway { + + private final List charges = new ArrayList<>(); + + @Override + public String charge(String orderId, int cents) { + String receipt = "charged " + cents + " cents for " + orderId; + charges.add(receipt); + return receipt; + } + + @Override + public List charges() { + return charges; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/Notifier.java b/core-di/src/main/java/com/ankurm/coredi/injection/Notifier.java new file mode 100644 index 0000000..3de46f0 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/Notifier.java @@ -0,0 +1,10 @@ +package com.ankurm.coredi.injection; + +import java.util.List; + +public interface Notifier { + + void send(String message); + + List sent(); +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/OptionalAudit.java b/core-di/src/main/java/com/ankurm/coredi/injection/OptionalAudit.java new file mode 100644 index 0000000..d532b64 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/OptionalAudit.java @@ -0,0 +1,18 @@ +package com.ankurm.coredi.injection; + +import org.springframework.beans.factory.annotation.Autowired; + +/** Setter injection's honest use: a dependency that is allowed to be absent. */ +public class OptionalAudit { + + private Notifier notifier; + + @Autowired(required = false) + public void setNotifier(Notifier notifier) { + this.notifier = notifier; + } + + public String status() { + return notifier == null ? "no notifier configured, auditing silently" : "notifier present"; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/OrderService.java b/core-di/src/main/java/com/ankurm/coredi/injection/OrderService.java new file mode 100644 index 0000000..0d56062 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/OrderService.java @@ -0,0 +1,7 @@ +package com.ankurm.coredi.injection; + +public interface OrderService { + + /** Charges the customer, then tells them. The order matters: see the setter and field variants. */ + String place(String orderId, int cents); +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/PaymentGateway.java b/core-di/src/main/java/com/ankurm/coredi/injection/PaymentGateway.java new file mode 100644 index 0000000..33d9083 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/PaymentGateway.java @@ -0,0 +1,10 @@ +package com.ankurm.coredi.injection; + +/** The collaborator every order service needs. */ +public interface PaymentGateway { + + String charge(String orderId, int cents); + + /** Every charge that really happened, so a test can prove a half-built service still took money. */ + java.util.List charges(); +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/RecordingNotifier.java b/core-di/src/main/java/com/ankurm/coredi/injection/RecordingNotifier.java new file mode 100644 index 0000000..ee92820 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/RecordingNotifier.java @@ -0,0 +1,19 @@ +package com.ankurm.coredi.injection; + +import java.util.ArrayList; +import java.util.List; + +public class RecordingNotifier implements Notifier { + + private final List sent = new ArrayList<>(); + + @Override + public void send(String message) { + sent.add(message); + } + + @Override + public List sent() { + return sent; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/SetterOrderService.java b/core-di/src/main/java/com/ankurm/coredi/injection/SetterOrderService.java new file mode 100644 index 0000000..2b8e326 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/SetterOrderService.java @@ -0,0 +1,27 @@ +package com.ankurm.coredi.injection; + +import org.springframework.beans.factory.annotation.Autowired; + +/** Setter injection. The object exists, half-built, before Spring calls the setters. */ +public class SetterOrderService implements OrderService { + + private PaymentGateway gateway; + private Notifier notifier; + + @Autowired + public void setGateway(PaymentGateway gateway) { + this.gateway = gateway; + } + + @Autowired + public void setNotifier(Notifier notifier) { + this.notifier = notifier; + } + + @Override + public String place(String orderId, int cents) { + String receipt = gateway.charge(orderId, cents); + notifier.send(receipt); + return receipt; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/Trace.java b/core-di/src/main/java/com/ankurm/coredi/injection/Trace.java new file mode 100644 index 0000000..6f2bafc --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/Trace.java @@ -0,0 +1,27 @@ +package com.ankurm.coredi.injection; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** A shared event log so a test can print the order in which Spring touches a bean. */ +public final class Trace { + + private static final List EVENTS = Collections.synchronizedList(new ArrayList<>()); + + private Trace() { + } + + public static void log(String event) { + EVENTS.add(event); + } + + public static List drain() { + List copy; + synchronized (EVENTS) { + copy = new ArrayList<>(EVENTS); + EVENTS.clear(); + } + return copy; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsAnnotated.java b/core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsAnnotated.java new file mode 100644 index 0000000..e5675d4 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsAnnotated.java @@ -0,0 +1,20 @@ +package com.ankurm.coredi.injection; + +import org.springframework.beans.factory.annotation.Autowired; + +/** The fix: say which constructor Spring should use. */ +public class TwoConstructorsAnnotated { + + private final PaymentGateway gateway; + + @Autowired + public TwoConstructorsAnnotated(PaymentGateway gateway) { + this.gateway = gateway; + Trace.log("annotated constructor used"); + } + + public TwoConstructorsAnnotated(PaymentGateway gateway, Notifier notifier) { + this.gateway = gateway; + Trace.log("two-arg constructor used"); + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsNoDefault.java b/core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsNoDefault.java new file mode 100644 index 0000000..10b18e0 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsNoDefault.java @@ -0,0 +1,11 @@ +package com.ankurm.coredi.injection; + +/** Two constructors, no no-argument one, none marked {@code @Autowired}: Spring cannot choose. */ +public class TwoConstructorsNoDefault { + + public TwoConstructorsNoDefault(PaymentGateway gateway) { + } + + public TwoConstructorsNoDefault(PaymentGateway gateway, Notifier notifier) { + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsService.java b/core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsService.java new file mode 100644 index 0000000..510b8cd --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/injection/TwoConstructorsService.java @@ -0,0 +1,20 @@ +package com.ankurm.coredi.injection; + +/** Two public constructors, none marked {@code @Autowired}: Spring falls back to the no-argument one. */ +public class TwoConstructorsService { + + private PaymentGateway gateway; + + public TwoConstructorsService() { + Trace.log("no-arg constructor used"); + } + + public TwoConstructorsService(PaymentGateway gateway) { + this.gateway = gateway; + Trace.log("one-arg constructor used"); + } + + public boolean hasGateway() { + return gateway != null; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/Broadcaster.java b/core-di/src/main/java/com/ankurm/coredi/resolution/Broadcaster.java new file mode 100644 index 0000000..0df6e99 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/Broadcaster.java @@ -0,0 +1,21 @@ +package com.ankurm.coredi.resolution; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.ankurm.coredi.injection.Notifier; + +/** Receives every Notifier three ways: as a List, a Map keyed by bean name, and a Set. */ +public class Broadcaster { + + public final List asList; + public final Map asMap; + public final Set asSet; + + public Broadcaster(List asList, Map asMap, Set asSet) { + this.asList = asList; + this.asMap = asMap; + this.asSet = asSet; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutFast.java b/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutFast.java new file mode 100644 index 0000000..ec842d2 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutFast.java @@ -0,0 +1,16 @@ +package com.ankurm.coredi.resolution; + +import com.ankurm.coredi.injection.PaymentGateway; + +public class CheckoutFast { + + private final PaymentGateway gateway; + + public CheckoutFast(@Fast PaymentGateway gateway) { + this.gateway = gateway; + } + + public String gatewayClass() { + return gateway.getClass().getSimpleName(); + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutNamed.java b/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutNamed.java new file mode 100644 index 0000000..1df1509 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutNamed.java @@ -0,0 +1,17 @@ +package com.ankurm.coredi.resolution; + +import com.ankurm.coredi.injection.PaymentGateway; + +/** The parameter name is the only hint: it matches the bean name {@code paypalGateway}. */ +public class CheckoutNamed { + + private final PaymentGateway paypalGateway; + + public CheckoutNamed(PaymentGateway paypalGateway) { + this.paypalGateway = paypalGateway; + } + + public String gatewayClass() { + return paypalGateway.getClass().getSimpleName(); + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutPlain.java b/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutPlain.java new file mode 100644 index 0000000..5697610 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutPlain.java @@ -0,0 +1,17 @@ +package com.ankurm.coredi.resolution; + +import com.ankurm.coredi.injection.PaymentGateway; + +/** Asks for a PaymentGateway and says nothing more. */ +public class CheckoutPlain { + + private final PaymentGateway gateway; + + public CheckoutPlain(PaymentGateway gateway) { + this.gateway = gateway; + } + + public String gatewayClass() { + return gateway.getClass().getSimpleName(); + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutQualified.java b/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutQualified.java new file mode 100644 index 0000000..5af2e81 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/CheckoutQualified.java @@ -0,0 +1,17 @@ +package com.ankurm.coredi.resolution; + +import com.ankurm.coredi.injection.PaymentGateway; +import org.springframework.beans.factory.annotation.Qualifier; + +public class CheckoutQualified { + + private final PaymentGateway gateway; + + public CheckoutQualified(@Qualifier("paypalGateway") PaymentGateway gateway) { + this.gateway = gateway; + } + + public String gatewayClass() { + return gateway.getClass().getSimpleName(); + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/EmailNotifier.java b/core-di/src/main/java/com/ankurm/coredi/resolution/EmailNotifier.java new file mode 100644 index 0000000..6adc180 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/EmailNotifier.java @@ -0,0 +1,8 @@ +package com.ankurm.coredi.resolution; + +import com.ankurm.coredi.injection.RecordingNotifier; +import org.springframework.core.annotation.Order; + +@Order(2) +public class EmailNotifier extends RecordingNotifier { +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/FallbackGateway.java b/core-di/src/main/java/com/ankurm/coredi/resolution/FallbackGateway.java new file mode 100644 index 0000000..462fe5a --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/FallbackGateway.java @@ -0,0 +1,8 @@ +package com.ankurm.coredi.resolution; + +import org.springframework.context.annotation.Fallback; + +/** Wins only when nothing else qualifies. */ +@Fallback +public class FallbackGateway extends PaypalGateway { +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/Fast.java b/core-di/src/main/java/com/ankurm/coredi/resolution/Fast.java new file mode 100644 index 0000000..db38301 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/Fast.java @@ -0,0 +1,15 @@ +package com.ankurm.coredi.resolution; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.annotation.Qualifier; + +/** A custom qualifier: a meta-annotation over {@code @Qualifier}, so call sites do not repeat a string. */ +@Target({ElementType.TYPE, ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Qualifier +public @interface Fast { +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/FastGateway.java b/core-di/src/main/java/com/ankurm/coredi/resolution/FastGateway.java new file mode 100644 index 0000000..d2b4985 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/FastGateway.java @@ -0,0 +1,5 @@ +package com.ankurm.coredi.resolution; + +@Fast +public class FastGateway extends StripeGateway { +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/GenericConsumer.java b/core-di/src/main/java/com/ankurm/coredi/resolution/GenericConsumer.java new file mode 100644 index 0000000..dd38b38 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/GenericConsumer.java @@ -0,0 +1,15 @@ +package com.ankurm.coredi.resolution; + +import java.util.List; + +/** The generic type argument is part of the injection point: only Handler<String> beans arrive. */ +public class GenericConsumer { + + public final List> stringHandlers; + public final Handler integerHandler; + + public GenericConsumer(List> stringHandlers, Handler integerHandler) { + this.stringHandlers = stringHandlers; + this.integerHandler = integerHandler; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/Handler.java b/core-di/src/main/java/com/ankurm/coredi/resolution/Handler.java new file mode 100644 index 0000000..2c2d47a --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/Handler.java @@ -0,0 +1,6 @@ +package com.ankurm.coredi.resolution; + +public interface Handler { + + String handle(T input); +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/IntegerHandler.java b/core-di/src/main/java/com/ankurm/coredi/resolution/IntegerHandler.java new file mode 100644 index 0000000..e95300c --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/IntegerHandler.java @@ -0,0 +1,9 @@ +package com.ankurm.coredi.resolution; + +public class IntegerHandler implements Handler { + + @Override + public String handle(Integer input) { + return "integer:" + input; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/OptionalConsumers.java b/core-di/src/main/java/com/ankurm/coredi/resolution/OptionalConsumers.java new file mode 100644 index 0000000..3558fd9 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/OptionalConsumers.java @@ -0,0 +1,53 @@ +package com.ankurm.coredi.resolution; + +import java.util.Optional; + +import com.ankurm.coredi.injection.Notifier; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.jspecify.annotations.Nullable; + +/** Every way to say "this dependency may not exist", one nested class each. */ +public final class OptionalConsumers { + + private OptionalConsumers() { + } + + public static class Required { + public Required(Notifier notifier) { + } + } + + public static class WithOptional { + public final Optional notifier; + + public WithOptional(Optional notifier) { + this.notifier = notifier; + } + } + + public static class WithNullable { + public final Notifier notifier; + + public WithNullable(@Nullable Notifier notifier) { + this.notifier = notifier; + } + } + + public static class WithProvider { + public final ObjectProvider provider; + + public WithProvider(ObjectProvider provider) { + this.provider = provider; + } + } + + public static class WithRequiredFalse { + public Notifier notifier; + + @Autowired(required = false) + public void setNotifier(Notifier notifier) { + this.notifier = notifier; + } + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/PaypalGateway.java b/core-di/src/main/java/com/ankurm/coredi/resolution/PaypalGateway.java new file mode 100644 index 0000000..4b1dd9b --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/PaypalGateway.java @@ -0,0 +1,6 @@ +package com.ankurm.coredi.resolution; + +import com.ankurm.coredi.injection.LoggingPaymentGateway; + +public class PaypalGateway extends LoggingPaymentGateway { +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/Plugin.java b/core-di/src/main/java/com/ankurm/coredi/resolution/Plugin.java new file mode 100644 index 0000000..05efcd1 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/Plugin.java @@ -0,0 +1,4 @@ +package com.ankurm.coredi.resolution; + +public interface Plugin { +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/PluginHosts.java b/core-di/src/main/java/com/ankurm/coredi/resolution/PluginHosts.java new file mode 100644 index 0000000..2af4d7f --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/PluginHosts.java @@ -0,0 +1,82 @@ +package com.ankurm.coredi.resolution; + +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.jspecify.annotations.Nullable; + +/** What a collection injection point does when zero beans of the element type exist. */ +public final class PluginHosts { + + private PluginHosts() { + } + + public static class Plain { + public Plain(List plugins) { + } + } + + /** Same collection, but injected into a field: no constructor "empty collection" fallback applies. */ + public static class FieldInjected { + @Autowired + public List plugins; + } + + /** Same collection, injected through a required setter. */ + public static class SetterInjected { + public List plugins; + + @Autowired + public void setPlugins(List plugins) { + this.plugins = plugins; + } + } + + /** Two constructors, one marked: the "single constructor" fallback for empty collections does not apply. */ + public static class TwoConstructors { + public List plugins; + + @Autowired + public TwoConstructors(List plugins) { + this.plugins = plugins; + } + + public TwoConstructors() { + } + } + + public static class WithOptional { + public final Optional> plugins; + + public WithOptional(Optional> plugins) { + this.plugins = plugins; + } + } + + public static class WithNullable { + public final List plugins; + + public WithNullable(@Nullable List plugins) { + this.plugins = plugins; + } + } + + public static class WithProvider { + public final List plugins; + + public WithProvider(ObjectProvider provider) { + this.plugins = provider.orderedStream().toList(); + } + } + + public static class WithRequiredFalse { + public List plugins = List.of(); + + @Autowired(required = false) + public void setPlugins(List plugins) { + this.plugins = plugins; + } + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/PrimaryStripeGateway.java b/core-di/src/main/java/com/ankurm/coredi/resolution/PrimaryStripeGateway.java new file mode 100644 index 0000000..8cd8f01 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/PrimaryStripeGateway.java @@ -0,0 +1,7 @@ +package com.ankurm.coredi.resolution; + +import org.springframework.context.annotation.Primary; + +@Primary +public class PrimaryStripeGateway extends StripeGateway { +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/PriorityOneGateway.java b/core-di/src/main/java/com/ankurm/coredi/resolution/PriorityOneGateway.java new file mode 100644 index 0000000..c92c2f2 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/PriorityOneGateway.java @@ -0,0 +1,7 @@ +package com.ankurm.coredi.resolution; + +import jakarta.annotation.Priority; + +@Priority(1) +public class PriorityOneGateway extends StripeGateway { +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/PriorityTwoGateway.java b/core-di/src/main/java/com/ankurm/coredi/resolution/PriorityTwoGateway.java new file mode 100644 index 0000000..60ced18 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/PriorityTwoGateway.java @@ -0,0 +1,7 @@ +package com.ankurm.coredi.resolution; + +import jakarta.annotation.Priority; + +@Priority(2) +public class PriorityTwoGateway extends PaypalGateway { +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/PushNotifier.java b/core-di/src/main/java/com/ankurm/coredi/resolution/PushNotifier.java new file mode 100644 index 0000000..0837a9f --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/PushNotifier.java @@ -0,0 +1,7 @@ +package com.ankurm.coredi.resolution; + +import com.ankurm.coredi.injection.RecordingNotifier; + +/** No {@code @Order}: sorts after every ordered bean. */ +public class PushNotifier extends RecordingNotifier { +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/ResourceConsumer.java b/core-di/src/main/java/com/ankurm/coredi/resolution/ResourceConsumer.java new file mode 100644 index 0000000..544aad2 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/ResourceConsumer.java @@ -0,0 +1,19 @@ +package com.ankurm.coredi.resolution; + +import com.ankurm.coredi.injection.PaymentGateway; +import jakarta.annotation.Resource; + +/** {@code @Resource} matches by name first; the field name is the default name. */ +public class ResourceConsumer { + + @Resource + PaymentGateway paypalGateway; + + @Resource(name = "stripeGateway") + PaymentGateway whateverIWantToCallIt; + + public String describe() { + return "paypalGateway field -> " + paypalGateway.getClass().getSimpleName() + + ", whateverIWantToCallIt field -> " + whateverIWantToCallIt.getClass().getSimpleName(); + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/SmsNotifier.java b/core-di/src/main/java/com/ankurm/coredi/resolution/SmsNotifier.java new file mode 100644 index 0000000..8f9e7b1 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/SmsNotifier.java @@ -0,0 +1,8 @@ +package com.ankurm.coredi.resolution; + +import com.ankurm.coredi.injection.RecordingNotifier; +import org.springframework.core.annotation.Order; + +@Order(1) +public class SmsNotifier extends RecordingNotifier { +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/StringHandler.java b/core-di/src/main/java/com/ankurm/coredi/resolution/StringHandler.java new file mode 100644 index 0000000..72bb367 --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/StringHandler.java @@ -0,0 +1,9 @@ +package com.ankurm.coredi.resolution; + +public class StringHandler implements Handler { + + @Override + public String handle(String input) { + return "string:" + input; + } +} diff --git a/core-di/src/main/java/com/ankurm/coredi/resolution/StripeGateway.java b/core-di/src/main/java/com/ankurm/coredi/resolution/StripeGateway.java new file mode 100644 index 0000000..a3fd69a --- /dev/null +++ b/core-di/src/main/java/com/ankurm/coredi/resolution/StripeGateway.java @@ -0,0 +1,6 @@ +package com.ankurm.coredi.resolution; + +import com.ankurm.coredi.injection.LoggingPaymentGateway; + +public class StripeGateway extends LoggingPaymentGateway { +} diff --git a/core-di/src/main/resources/application.yml b/core-di/src/main/resources/application.yml new file mode 100644 index 0000000..364fe60 --- /dev/null +++ b/core-di/src/main/resources/application.yml @@ -0,0 +1,5 @@ +spring: + application: + name: core-di + main: + banner-mode: off diff --git a/core-di/src/test/java/com/ankurm/coredi/CandidateResolutionTest.java b/core-di/src/test/java/com/ankurm/coredi/CandidateResolutionTest.java new file mode 100644 index 0000000..a22df96 --- /dev/null +++ b/core-di/src/test/java/com/ankurm/coredi/CandidateResolutionTest.java @@ -0,0 +1,247 @@ +package com.ankurm.coredi; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.coredi.injection.*; +import com.ankurm.coredi.resolution.*; +import org.junit.jupiter.api.Test; +import org.springframework.context.support.GenericApplicationContext; + +/** Post 17: how @Autowired chooses between candidates. */ +class CandidateResolutionTest { + + @Test + void twoCandidatesNoHint() { + try (var t = new Transcript("10-no-unique-bean.txt", "Two PaymentGateway beans and a consumer that asks for one")) { + var outcome = Ctx.tryStart(StripeGateway.class, PaypalGateway.class, CheckoutPlain.class); + assertThat(outcome.started()).isFalse(); + t.line("started: false"); + t.line("top exception : %s", outcome.failure().getClass().getName()); + t.line("top message : %s", outcome.failure().getMessage()); + t.blank(); + t.line("root cause : %s", Ctx.root(outcome.failure()).getClass().getName()); + t.line("root message : %s", Ctx.root(outcome.failure()).getMessage()); + } + } + + @Test + void theResolutionLadder() { + try (var t = new Transcript("11-resolution-ladder.txt", + "Who wins when several rules apply at once? (one mini-context per row)")) { + t.line("%-58s %s", "beans registered / injection point", "winner"); + t.line("%-58s %s", "-".repeat(58), "-".repeat(20)); + + row(t, "@Primary Stripe + Paypal; CheckoutPlain", PrimaryStripeGateway.class, PaypalGateway.class, + CheckoutPlain.class); + row(t, "@Primary Stripe + Paypal; @Qualifier(\"paypalGateway\")", PrimaryStripeGateway.class, + PaypalGateway.class, CheckoutQualified.class); + row(t, "@Priority(1) Stripe + @Priority(2) Paypal; CheckoutPlain", PriorityOneGateway.class, + PriorityTwoGateway.class, CheckoutPlain.class); + row(t, "@Priority(1) + Paypal(no priority); param named paypalGateway", PriorityOneGateway.class, + PaypalGateway.class, CheckoutNamed.class); + row(t, "Stripe + Paypal (plain); param named paypalGateway", StripeGateway.class, PaypalGateway.class, + CheckoutNamed.class); + row(t, "Stripe (plain) + @Fallback Paypal; CheckoutPlain", StripeGateway.class, FallbackGateway.class, + CheckoutPlain.class); + row(t, "@Fast custom qualifier, FastGateway + Paypal; @Fast param", FastGateway.class, PaypalGateway.class, + CheckoutFast.class); + } + } + + private static void row(Transcript t, String label, Class... classes) { + var outcome = Ctx.tryStart(classes); + if (outcome.started()) { + Object consumer = null; + for (Class c : classes) { + if (c.getSimpleName().startsWith("Checkout")) { + consumer = outcome.context().getBean(c); + } + } + String winner; + try { + winner = (String) consumer.getClass().getMethod("gatewayClass").invoke(consumer); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + t.line("%-58s %s", label, winner); + outcome.closeQuietly(); + } else { + t.line("%-58s FAILED %s", label, Ctx.root(outcome.failure()).getClass().getSimpleName()); + } + } + + @Test + void parameterNameFallbackNeedsTheParametersFlag() throws Exception { + try (var t = new Transcript("18-parameters-flag.txt", + "The parameter-name fallback needs javac -parameters (compiled twice from the same source)")) { + String source = """ + package demo; + import com.ankurm.coredi.injection.PaymentGateway; + public class NamedConsumer { + private final PaymentGateway paypalGateway; + public NamedConsumer(PaymentGateway paypalGateway) { this.paypalGateway = paypalGateway; } + public String picked() { return paypalGateway.getClass().getSimpleName(); } + } + """; + for (boolean flag : new boolean[] {true, false}) { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("named"); + java.nio.file.Path src = dir.resolve("demo/NamedConsumer.java"); + java.nio.file.Files.createDirectories(src.getParent()); + java.nio.file.Files.writeString(src, source); + var compiler = javax.tools.ToolProvider.getSystemJavaCompiler(); + java.util.List args = new java.util.ArrayList<>(java.util.List.of( + "-cp", System.getProperty("java.class.path"), "-d", dir.toString())); + if (flag) { + args.add("-parameters"); + } + args.add(src.toString()); + assertThat(compiler.run(null, null, null, args.toArray(String[]::new))).isZero(); + try (var loader = new java.net.URLClassLoader(new java.net.URL[] {dir.toUri().toURL()}, + getClass().getClassLoader())) { + Class consumer = loader.loadClass("demo.NamedConsumer"); + var outcome = Ctx.tryStart(StripeGateway.class, PaypalGateway.class, consumer); + if (outcome.started()) { + Object bean = outcome.context().getBean(consumer); + t.line("javac %-12s : started, picked %s", flag ? "-parameters" : "(no flag)", + consumer.getMethod("picked").invoke(bean)); + outcome.closeQuietly(); + } else { + t.line("javac %-12s : FAILED %s", flag ? "-parameters" : "(no flag)", + Ctx.root(outcome.failure()).getClass().getSimpleName()); + t.line(" %s", Ctx.root(outcome.failure()).getMessage()); + } + assertThat(outcome.started()).isEqualTo(flag); + } + } + } + } + + @Test + void missingBean() { + try (var t = new Transcript("12-missing-bean.txt", "No PaymentGateway bean at all")) { + var outcome = Ctx.tryStart(CheckoutPlain.class); + assertThat(outcome.started()).isFalse(); + t.line("top exception : %s", outcome.failure().getClass().getName()); + t.line("top message : %s", outcome.failure().getMessage()); + t.blank(); + t.line("root cause : %s", Ctx.root(outcome.failure()).getClass().getName()); + t.line("root message : %s", Ctx.root(outcome.failure()).getMessage()); + } + } + + @Test + void optionalWaysToAbsent() { + try (var t = new Transcript("13-optional-and-objectprovider.txt", + "Five ways to say 'this may not exist', with zero and with two Notifier beans (Email registered before Sms)")) { + t.section("zero Notifier beans"); + var required = Ctx.tryStart(OptionalConsumers.Required.class); + t.line("plain constructor param : %s", required.started() ? "started" : "FAILED " + Ctx.root(required.failure()).getClass().getSimpleName()); + var opt = Ctx.plain(OptionalConsumers.WithOptional.class); + t.line("Optional : isPresent=%s", opt.getBean(OptionalConsumers.WithOptional.class).notifier.isPresent()); + var nul = Ctx.plain(OptionalConsumers.WithNullable.class); + t.line("@Nullable Notifier : value=%s", nul.getBean(OptionalConsumers.WithNullable.class).notifier); + var prov = Ctx.plain(OptionalConsumers.WithProvider.class); + var p = prov.getBean(OptionalConsumers.WithProvider.class).provider; + t.line("ObjectProvider.getIfAvailable() : %s", p.getIfAvailable()); + t.line("ObjectProvider.getIfUnique() : %s", p.getIfUnique()); + t.line("ObjectProvider.getObject() : %s", Ctx.attempt(p::getObject)); + var rf = Ctx.plain(OptionalConsumers.WithRequiredFalse.class); + t.line("@Autowired(required=false) setter: value=%s", rf.getBean(OptionalConsumers.WithRequiredFalse.class).notifier); + assertThat(required.started()).isFalse(); + assertThat(opt.getBean(OptionalConsumers.WithOptional.class).notifier).isEmpty(); + opt.close(); nul.close(); prov.close(); rf.close(); + + t.section("two Notifier beans (Sms, Email), no @Primary"); + var two = Ctx.plain(EmailNotifier.class, SmsNotifier.class, OptionalConsumers.WithProvider.class); + var p2 = two.getBean(OptionalConsumers.WithProvider.class).provider; + t.line("ObjectProvider.getIfAvailable() : %s", Ctx.attempt(p2::getIfAvailable)); + t.line("ObjectProvider.getIfUnique() : %s", p2.getIfUnique()); + t.line("ObjectProvider.stream() classes : %s", p2.stream().map(n -> n.getClass().getSimpleName()).toList()); + t.line("ObjectProvider.orderedStream() : %s", p2.orderedStream().map(n -> n.getClass().getSimpleName()).toList()); + two.close(); + var optTwo = Ctx.tryStart(EmailNotifier.class, SmsNotifier.class, OptionalConsumers.WithOptional.class); + assertThat(optTwo.started()).isFalse(); + t.line("Optional with two beans: FAILED %s", Ctx.root(optTwo.failure()).getClass().getSimpleName()); + t.line(" %s", Ctx.root(optTwo.failure()).getMessage()); + } + } + + @Test + void collectionInjection() { + try (var t = new Transcript("14-list-map-set-injection.txt", + "List, Map and Set injection; registered Push, Email, Sms in that order")) { + try (var ctx = Ctx.plain(PushNotifier.class, EmailNotifier.class, SmsNotifier.class, Broadcaster.class)) { + var b = ctx.getBean(Broadcaster.class); + t.line("List order : %s", b.asList.stream().map(n -> n.getClass().getSimpleName()).toList()); + t.line("Map : %s", b.asMap.keySet()); + t.line("Set size : %d", b.asSet.size()); + assertThat(b.asList.stream().map(n -> n.getClass().getSimpleName()).toList()) + .containsExactly("SmsNotifier", "EmailNotifier", "PushNotifier"); + assertThat(b.asMap.keySet()).containsExactly("pushNotifier", "emailNotifier", "smsNotifier"); + } + } + } + + @Test + void emptyCollections() { + try (var t = new Transcript("15-empty-collection.txt", "A List injection point when zero Plugin beans exist")) { + t.section("required List, zero Plugin beans"); + var plain = Ctx.tryStart(PluginHosts.Plain.class); + t.line("single constructor param : %s", plain.started() ? "started (empty list injected)" : "FAILED"); + assertThat(plain.started()).isTrue(); + assertThat(plain.context().getBean(PluginHosts.Plain.class)).isNotNull(); + plain.closeQuietly(); + var annotated = Ctx.tryStart(PluginHosts.TwoConstructors.class); + t.line("@Autowired ctor + no-arg ctor : %s", annotated.started() ? "started (empty list injected)" : "FAILED"); + assertThat(annotated.started()).isTrue(); + annotated.closeQuietly(); + for (Class host : new Class[] {PluginHosts.FieldInjected.class, PluginHosts.SetterInjected.class}) { + var o = Ctx.tryStart(host); + assertThat(o.started()).isFalse(); + t.line("%-26s : FAILED %s", host.getSimpleName(), Ctx.root(o.failure()).getClass().getSimpleName()); + t.line(" %s", Ctx.root(o.failure()).getMessage()); + } + + t.section("ways to make an absent collection legal"); + var opt = Ctx.plain(PluginHosts.WithOptional.class); + t.line("Optional> : isPresent=%s", opt.getBean(PluginHosts.WithOptional.class).plugins.isPresent()); + var nul = Ctx.plain(PluginHosts.WithNullable.class); + t.line("@Nullable List : value=%s", nul.getBean(PluginHosts.WithNullable.class).plugins); + var prov = Ctx.plain(PluginHosts.WithProvider.class); + t.line("ObjectProvider -> list : %s", prov.getBean(PluginHosts.WithProvider.class).plugins); + var rf = Ctx.plain(PluginHosts.WithRequiredFalse.class); + t.line("@Autowired(required=false) list: %s", rf.getBean(PluginHosts.WithRequiredFalse.class).plugins); + opt.close(); nul.close(); prov.close(); rf.close(); + } + } + + @Test + void genericsAndResource() { + try (var t = new Transcript("16-generics-and-resource.txt", "Generic type arguments and @Resource")) { + try (var ctx = Ctx.plain(StringHandler.class, IntegerHandler.class, GenericConsumer.class)) { + var c = ctx.getBean(GenericConsumer.class); + t.line("List> : %s", c.stringHandlers.stream().map(h -> h.getClass().getSimpleName()).toList()); + t.line("Handler : %s", c.integerHandler.getClass().getSimpleName()); + assertThat(c.stringHandlers).hasSize(1); + } + try (var ctx = Ctx.plain(StripeGateway.class, PaypalGateway.class, ResourceConsumer.class)) { + t.line("@Resource : %s", ctx.getBean(ResourceConsumer.class).describe()); + } + } + } + + @Test + void dependencyGraph() { + try (var t = new Transcript("17-dependency-graph.txt", + "getDependenciesForBean: who did Spring wire into whom?")) { + try (var ctx = Ctx.plain(BaseBeans.class, ConstructorOrderService.class, SetterOrderService.class, + FieldOrderService.class)) { + var bf = ctx.getBeanFactory(); + for (String name : new String[] {"constructorOrderService", "setterOrderService", "fieldOrderService"}) { + t.line("%-24s depends on %s", name, java.util.Arrays.toString(bf.getDependenciesForBean(name))); + } + t.line("%-24s is used by %s", "paymentGateway", java.util.Arrays.toString(bf.getDependentBeans("paymentGateway"))); + } + } + } +} diff --git a/core-di/src/test/java/com/ankurm/coredi/CircularDependencyTest.java b/core-di/src/test/java/com/ankurm/coredi/CircularDependencyTest.java new file mode 100644 index 0000000..6979f50 --- /dev/null +++ b/core-di/src/test/java/com/ankurm/coredi/CircularDependencyTest.java @@ -0,0 +1,86 @@ +package com.ankurm.coredi; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ankurm.coredi.injection.*; +import org.junit.jupiter.api.Test; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Configuration; + +/** Post 16: what Spring does with A -> B -> A, in plain Spring and under Boot. */ +class CircularDependencyTest { + + @Configuration + static class ConstructorCycleConfig { + } + + @Test + void plainSpringVersusBoot() { + try (var t = new Transcript("08-circular-plain-vs-boot.txt", + "Constructor cycle vs field cycle: plain Spring and Spring Boot")) { + + t.section("plain Spring, constructor cycle (CtorA <-> CtorB)"); + var ctor = Ctx.tryStart(CtorA.class, CtorB.class); + assertThat(ctor.started()).isFalse(); + t.line("started: false"); + t.line("root cause: %s", Ctx.root(ctor.failure()).getClass().getName()); + t.line("%s", Ctx.root(ctor.failure()).getMessage()); + + t.section("plain Spring, field cycle (FieldA <-> FieldB)"); + var field = Ctx.tryStart(FieldA.class, FieldB.class); + t.line("started: %s", field.started()); + assertThat(field.started()).isTrue(); + t.line("FieldA.b is FieldB: %s", field.context().getBean(FieldA.class).b == field.context().getBean(FieldB.class)); + field.closeQuietly(); + + t.section("Spring Boot, field cycle, default settings"); + String defaultBoot = bootStart(false, FieldA.class, FieldB.class); + t.line("%s", defaultBoot); + assertThat(defaultBoot).startsWith("FAILED"); + + t.section("Spring Boot, field cycle, spring.main.allow-circular-references=true"); + String allowed = bootStart(true, FieldA.class, FieldB.class); + t.line("%s", allowed); + assertThat(allowed).startsWith("started"); + + t.section("Spring Boot, constructor cycle, spring.main.allow-circular-references=true"); + String ctorAllowed = bootStart(true, CtorA.class, CtorB.class); + t.line("%s", ctorAllowed); + assertThat(ctorAllowed).startsWith("FAILED"); + } + } + + @Test + void lazyBreaksAConstructorCycle() { + try (var t = new Transcript("09-lazy-breaks-constructor-cycle.txt", + "@Lazy on one constructor parameter breaks a constructor cycle")) { + var outcome = Ctx.tryStart(LazyA.class, LazyB.class); + assertThat(outcome.started()).isTrue(); + var a = outcome.context().getBean(LazyA.class); + t.line("context started: true"); + t.line("LazyA.b runtime class : %s", a.b.getClass().getName()); + t.line("LazyA.b.hello() : %s", a.b.hello()); + t.line("LazyA.b is the real LazyB bean: %s", a.b == outcome.context().getBean(LazyB.class)); + assertThat(a.b.getClass().getName()).contains("SpringCGLIB"); + outcome.closeQuietly(); + } + } + + private static String bootStart(boolean allowCircular, Class... beans) { + var builder = new SpringApplicationBuilder(beans) + .web(WebApplicationType.NONE) + .logStartupInfo(false) + .properties("logging.level.root=OFF", "spring.main.banner-mode=off"); + if (allowCircular) { + builder.properties("spring.main.allow-circular-references=true"); + } + try (ConfigurableApplicationContext ctx = builder.run()) { + return "started"; + } catch (Exception e) { + Throwable root = Ctx.root(e); + return "FAILED: " + root.getClass().getSimpleName() + ": " + root.getMessage(); + } + } +} diff --git a/core-di/src/test/java/com/ankurm/coredi/Ctx.java b/core-di/src/test/java/com/ankurm/coredi/Ctx.java new file mode 100644 index 0000000..5e88314 --- /dev/null +++ b/core-di/src/test/java/com/ankurm/coredi/Ctx.java @@ -0,0 +1,61 @@ +package com.ankurm.coredi; + +import java.util.concurrent.Callable; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +/** Small helpers so each test reads as the scenario, not the plumbing. */ +public final class Ctx { + + private Ctx() { + } + + /** A plain Spring context (no Boot) with the given classes registered and refreshed. */ + public static AnnotationConfigApplicationContext plain(Class... classes) { + var ctx = new AnnotationConfigApplicationContext(); + ctx.register(classes); + ctx.refresh(); + return ctx; + } + + /** Refreshes a context and returns either "started" or the exception, so failures can be printed. */ + public static Outcome tryStart(Class... classes) { + var ctx = new AnnotationConfigApplicationContext(); + try { + ctx.register(classes); + ctx.refresh(); + return new Outcome(ctx, null); + } catch (RuntimeException e) { + ctx.close(); + return new Outcome(null, e); + } + } + + public static String attempt(Callable call) { + try { + return "OK -> " + call.call(); + } catch (Exception e) { + return e.getClass().getSimpleName() + ": " + e.getMessage(); + } + } + + public static Throwable root(Throwable t) { + while (t.getCause() != null && t.getCause() != t) { + t = t.getCause(); + } + return t; + } + + public record Outcome(AnnotationConfigApplicationContext context, RuntimeException failure) { + + public boolean started() { + return failure == null; + } + + public void closeQuietly() { + if (context != null) { + context.close(); + } + } + } +} diff --git a/core-di/src/test/java/com/ankurm/coredi/InjectionStylesTest.java b/core-di/src/test/java/com/ankurm/coredi/InjectionStylesTest.java new file mode 100644 index 0000000..fded813 --- /dev/null +++ b/core-di/src/test/java/com/ankurm/coredi/InjectionStylesTest.java @@ -0,0 +1,151 @@ +package com.ankurm.coredi; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.List; + +import com.ankurm.coredi.injection.*; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +/** Post 16: the same service, three injection styles. */ +class InjectionStylesTest { + + @Test + void threeStylesUnderPlainNew() { + try (var t = new Transcript("01-three-styles-under-plain-new.txt", + "The same OrderService built with plain 'new', no Spring anywhere")) { + + t.section("constructor injection"); + var gateway = new LoggingPaymentGateway(); + var notifier = new RecordingNotifier(); + var ctor = new ConstructorOrderService(gateway, notifier); + t.line("new ConstructorOrderService(gateway, notifier).place(\"A-1\", 500)"); + t.line(" %s", Ctx.attempt(() -> ctor.place("A-1", 500))); + t.line(" charges=%d, notifications=%d", gateway.charges().size(), notifier.sent().size()); + + t.section("setter injection, one setter forgotten"); + var gateway2 = new LoggingPaymentGateway(); + var setter = new SetterOrderService(); + setter.setGateway(gateway2); + String setterResult = Ctx.attempt(() -> setter.place("A-2", 500)); + t.line("new SetterOrderService(); setter.setGateway(gateway); // setNotifier never called"); + t.line(" %s", setterResult); + t.line(" charges=%d <-- the customer was charged before the failure", gateway2.charges().size()); + + t.section("field injection"); + var field = new FieldOrderService(); + String fieldResult = Ctx.attempt(() -> field.place("A-3", 500)); + t.line("new FieldOrderService().place(\"A-3\", 500)"); + t.line(" %s", fieldResult); + var gateway3 = new LoggingPaymentGateway(); + ReflectionTestUtils.setField(field, "gateway", gateway3); + ReflectionTestUtils.setField(field, "notifier", new RecordingNotifier()); + t.line("after ReflectionTestUtils.setField(...) twice:"); + t.line(" %s", Ctx.attempt(() -> field.place("A-3", 500))); + + assertThat(setterResult).startsWith("NullPointerException"); + assertThat(gateway2.charges()).hasSize(1); + assertThat(fieldResult).startsWith("NullPointerException"); + } + } + + @Test + void fieldsAndFinality() { + try (var t = new Transcript("02-fields-and-finality.txt", + "Which injected fields can be final? (reflection over the three variants)")) { + for (Class type : List.of(ConstructorOrderService.class, SetterOrderService.class, + FieldOrderService.class)) { + for (Field f : type.getDeclaredFields()) { + t.line("%-24s %-10s final=%s", type.getSimpleName(), f.getName(), + Modifier.isFinal(f.getModifiers())); + } + } + for (Field f : ConstructorOrderService.class.getDeclaredFields()) { + assertThat(Modifier.isFinal(f.getModifiers())).isTrue(); + } + for (Field f : FieldOrderService.class.getDeclaredFields()) { + assertThat(Modifier.isFinal(f.getModifiers())).isFalse(); + } + } + } + + @Test + void allThreeStylesSideBySide() { + Trace.drain(); + try (var t = new Transcript("03-injection-order.txt", + "The order Spring touches one bean that uses all three styles")) { + try (var ctx = Ctx.plain(BaseBeans.class, AllThreeStyles.class)) { + Trace.drain().forEach(e -> t.line("%s", e)); + } + } + } + + @Test + void fieldInjectedDependencyUsedInConstructor() { + try (var t = new Transcript("04-field-used-in-constructor.txt", + "A field-injected collaborator used in the constructor")) { + var outcome = Ctx.tryStart(BaseBeans.class, FieldTrapService.class); + t.line("context started: %s", outcome.started()); + assertThat(outcome.started()).isFalse(); + t.line("top exception : %s", outcome.failure().getClass().getName()); + t.line("top message : %s", outcome.failure().getMessage()); + t.line("root cause : %s", Ctx.root(outcome.failure())); + } + } + + @Test + void multipleConstructors() { + Trace.drain(); + try (var t = new Transcript("05-multiple-constructors.txt", + "Two constructors: which one does Spring pick?")) { + t.section("TwoConstructorsService: no-arg + one-arg, neither annotated"); + try (var ctx = Ctx.plain(BaseBeans.class, TwoConstructorsService.class)) { + t.line("%s", Trace.drain()); + t.line("gateway injected: %s", ctx.getBean(TwoConstructorsService.class).hasGateway()); + assertThat(ctx.getBean(TwoConstructorsService.class).hasGateway()).isFalse(); + } + + t.section("TwoConstructorsNoDefault: one-arg + two-arg, neither annotated"); + var outcome = Ctx.tryStart(BaseBeans.class, TwoConstructorsNoDefault.class); + assertThat(outcome.started()).isFalse(); + t.line("%s", outcome.failure().getClass().getName()); + t.line("%s", outcome.failure().getMessage()); + t.line("root cause: %s", Ctx.root(outcome.failure())); + + t.section("TwoConstructorsAnnotated: @Autowired on one constructor"); + try (var ctx = Ctx.plain(BaseBeans.class, TwoConstructorsAnnotated.class)) { + t.line("%s", Trace.drain()); + } + } + } + + @Test + void optionalDependencyViaSetter() { + try (var t = new Transcript("06-optional-setter.txt", "@Autowired(required = false) on a setter")) { + try (var with = Ctx.plain(BaseBeans.class, OptionalAudit.class)) { + t.line("with a Notifier bean : %s", with.getBean(OptionalAudit.class).status()); + } + try (var without = Ctx.plain(OptionalAudit.class)) { + t.line("without a Notifier bean : %s", without.getBean(OptionalAudit.class).status()); + assertThat(without.getBean(OptionalAudit.class).status()).contains("no notifier"); + } + } + } + + @Test + void springWiresAllThreeIdentically() { + try (var t = new Transcript("07-spring-wires-all-three.txt", "Inside a Spring context all three styles work")) { + try (var ctx = Ctx.plain(BaseBeans.class, ConstructorOrderService.class, SetterOrderService.class, + FieldOrderService.class)) { + for (Class type : List.of(ConstructorOrderService.class, + SetterOrderService.class, FieldOrderService.class)) { + t.line("%-24s -> %s", type.getSimpleName(), ctx.getBean(type).place("B-1", 700)); + } + assertThat(ctx.getBean(PaymentGateway.class).charges()).hasSize(3); + } + } + } +} diff --git a/core-di/src/test/java/com/ankurm/coredi/Transcript.java b/core-di/src/test/java/com/ankurm/coredi/Transcript.java new file mode 100644 index 0000000..577f545 --- /dev/null +++ b/core-di/src/test/java/com/ankurm/coredi/Transcript.java @@ -0,0 +1,52 @@ +package com.ankurm.coredi; + +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(); + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, buffer.toString()); + } catch (IOException e) { + throw new IllegalStateException("could not write " + path, e); + } + System.out.print(buffer); + } +}