Add core-di: constructor vs setter vs field injection and @Autowired candidate resolution on Boot 4.1

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JoVmf2fWvcpoXndcDSwRf7
This commit is contained in:
Claude
2026-09-24 04:59:40 +00:00
parent b0c39a8d4c
commit 9825efa081
87 changed files with 1945 additions and 0 deletions
+5
View File
@@ -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
`<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and
+2
View File
@@ -0,0 +1,2 @@
target/
*.class
+86
View File
@@ -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<Plugin> 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.
@@ -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
@@ -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
+5
View File
@@ -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
@@ -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
@@ -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.<init>()
--- TwoConstructorsAnnotated: @Autowired on one constructor ---
[annotated constructor used]
+4
View File
@@ -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
@@ -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
@@ -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?
@@ -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
+8
View File
@@ -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
+11
View File
@@ -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
+7
View File
@@ -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: {}
@@ -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<Notifier> : 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<Notifier> 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
@@ -0,0 +1,5 @@
# List, Map and Set injection; registered Push, Email, Sms in that order
List<Notifier> order : [SmsNotifier, EmailNotifier, PushNotifier]
Map<String,Notifier> : [pushNotifier, emailNotifier, smsNotifier]
Set<Notifier> size : 3
+16
View File
@@ -0,0 +1,16 @@
# A List<Plugin> injection point when zero Plugin beans exist
--- required List<Plugin>, 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<com.ankurm.coredi.resolution.Plugin>' 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<com.ankurm.coredi.resolution.Plugin>' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
--- ways to make an absent collection legal ---
Optional<List<Plugin>> : isPresent=false
@Nullable List<Plugin> : value=null
ObjectProvider<Plugin> -> list : []
@Autowired(required=false) list: []
@@ -0,0 +1,5 @@
# Generic type arguments and @Resource
List<Handler<String>> : [StringHandler]
Handler<Integer> : IntegerHandler
@Resource : paypalGateway field -> PaypalGateway, whateverIWantToCallIt field -> StripeGateway
+6
View File
@@ -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]
+5
View File
@@ -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
@@ -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.
@@ -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.
@@ -0,0 +1,3 @@
# Same field cycle with --spring.main.allow-circular-references=true
STARTED. Beans from the cycle: [bootCycles.FieldOne, bootCycles.FieldTwo]
+3
View File
@@ -0,0 +1,3 @@
# @Lazy on one constructor parameter: --spring.profiles.active=lazy-cycle
STARTED. Beans from the cycle: [bootCycles.LazyOne, bootCycles.LazyTwo]
@@ -0,0 +1,28 @@
# DefaultListableBeanFactory.determineAutowireCandidate, read with javap
# jar: spring-beans-7.0.9.jar
protected java.lang.String determineAutowireCandidate(java.util.Map<java.lang.String, java.lang.Object>, 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
@@ -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<java.lang.String, java.lang.Object> singletonObjects;
private final java.util.Map<java.lang.String, org.springframework.beans.factory.ObjectFactory<?>> singletonFactories;
private final java.util.Map<java.lang.String, java.lang.Object> earlySingletonObjects;
private final java.util.Set<java.lang.String> singletonsCurrentlyInCreation;
# DefaultListableBeanFactory / AbstractAutowireCapableBeanFactory: the allow-circular-references switch
private boolean allowCircularReferences;
public void setAllowCircularReferences(boolean);
public boolean isAllowCircularReferences();
@@ -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"
@@ -0,0 +1,7 @@
# spring-boot-starter-parent-4.1.1.pom: the compiler flag that parameter-name matching depends on
111- <artifactId>maven-compiler-plugin</artifactId>
112- <configuration>
113: <parameters>true</parameters>
114- </configuration>
115- </plugin>
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>core-di</artifactId>
<version>1.0.0</version>
<name>core-di</name>
<description>Dependency injection in Spring Boot 4: constructor vs setter vs field, and how @Autowired resolves candidates</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+54
View File
@@ -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 '<parameters>' "$PARENT" || echo "(no <parameters> element found)"
} > output/26-boot-parent-parameters-flag.txt
cat output/26-boot-parent-parameters-flag.txt
+42
View File
@@ -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
+25
View File
@@ -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
@@ -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);
}
}
@@ -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) {
}
}
}
@@ -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";
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.coredi.injection;
public class CtorA {
public CtorA(CtorB b) {
}
}
@@ -0,0 +1,6 @@
package com.ankurm.coredi.injection;
public class CtorB {
public CtorB(CtorA a) {
}
}
@@ -0,0 +1,8 @@
package com.ankurm.coredi.injection;
import org.springframework.beans.factory.annotation.Autowired;
public class FieldA {
@Autowired
public FieldB b;
}
@@ -0,0 +1,8 @@
package com.ankurm.coredi.injection;
import org.springframework.beans.factory.annotation.Autowired;
public class FieldB {
@Autowired
public FieldA a;
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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";
}
}
@@ -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<String> 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<String> charges() {
return charges;
}
}
@@ -0,0 +1,10 @@
package com.ankurm.coredi.injection;
import java.util.List;
public interface Notifier {
void send(String message);
List<String> sent();
}
@@ -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";
}
}
@@ -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);
}
@@ -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<String> charges();
}
@@ -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<String> sent = new ArrayList<>();
@Override
public void send(String message) {
sent.add(message);
}
@Override
public List<String> sent() {
return sent;
}
}
@@ -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;
}
}
@@ -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<String> EVENTS = Collections.synchronizedList(new ArrayList<>());
private Trace() {
}
public static void log(String event) {
EVENTS.add(event);
}
public static List<String> drain() {
List<String> copy;
synchronized (EVENTS) {
copy = new ArrayList<>(EVENTS);
EVENTS.clear();
}
return copy;
}
}
@@ -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");
}
}
@@ -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) {
}
}
@@ -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;
}
}
@@ -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<Notifier> asList;
public final Map<String, Notifier> asMap;
public final Set<Notifier> asSet;
public Broadcaster(List<Notifier> asList, Map<String, Notifier> asMap, Set<Notifier> asSet) {
this.asList = asList;
this.asMap = asMap;
this.asSet = asSet;
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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 {
}
@@ -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 {
}
@@ -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 {
}
@@ -0,0 +1,5 @@
package com.ankurm.coredi.resolution;
@Fast
public class FastGateway extends StripeGateway {
}
@@ -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&lt;String&gt; beans arrive. */
public class GenericConsumer {
public final List<Handler<String>> stringHandlers;
public final Handler<Integer> integerHandler;
public GenericConsumer(List<Handler<String>> stringHandlers, Handler<Integer> integerHandler) {
this.stringHandlers = stringHandlers;
this.integerHandler = integerHandler;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.coredi.resolution;
public interface Handler<T> {
String handle(T input);
}
@@ -0,0 +1,9 @@
package com.ankurm.coredi.resolution;
public class IntegerHandler implements Handler<Integer> {
@Override
public String handle(Integer input) {
return "integer:" + input;
}
}
@@ -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> notifier;
public WithOptional(Optional<Notifier> 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<Notifier> provider;
public WithProvider(ObjectProvider<Notifier> provider) {
this.provider = provider;
}
}
public static class WithRequiredFalse {
public Notifier notifier;
@Autowired(required = false)
public void setNotifier(Notifier notifier) {
this.notifier = notifier;
}
}
}
@@ -0,0 +1,6 @@
package com.ankurm.coredi.resolution;
import com.ankurm.coredi.injection.LoggingPaymentGateway;
public class PaypalGateway extends LoggingPaymentGateway {
}
@@ -0,0 +1,4 @@
package com.ankurm.coredi.resolution;
public interface Plugin {
}
@@ -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<Plugin> plugins) {
}
}
/** Same collection, but injected into a field: no constructor "empty collection" fallback applies. */
public static class FieldInjected {
@Autowired
public List<Plugin> plugins;
}
/** Same collection, injected through a required setter. */
public static class SetterInjected {
public List<Plugin> plugins;
@Autowired
public void setPlugins(List<Plugin> plugins) {
this.plugins = plugins;
}
}
/** Two constructors, one marked: the "single constructor" fallback for empty collections does not apply. */
public static class TwoConstructors {
public List<Plugin> plugins;
@Autowired
public TwoConstructors(List<Plugin> plugins) {
this.plugins = plugins;
}
public TwoConstructors() {
}
}
public static class WithOptional {
public final Optional<List<Plugin>> plugins;
public WithOptional(Optional<List<Plugin>> plugins) {
this.plugins = plugins;
}
}
public static class WithNullable {
public final List<Plugin> plugins;
public WithNullable(@Nullable List<Plugin> plugins) {
this.plugins = plugins;
}
}
public static class WithProvider {
public final List<Plugin> plugins;
public WithProvider(ObjectProvider<Plugin> provider) {
this.plugins = provider.orderedStream().toList();
}
}
public static class WithRequiredFalse {
public List<Plugin> plugins = List.of();
@Autowired(required = false)
public void setPlugins(List<Plugin> plugins) {
this.plugins = plugins;
}
}
}
@@ -0,0 +1,7 @@
package com.ankurm.coredi.resolution;
import org.springframework.context.annotation.Primary;
@Primary
public class PrimaryStripeGateway extends StripeGateway {
}
@@ -0,0 +1,7 @@
package com.ankurm.coredi.resolution;
import jakarta.annotation.Priority;
@Priority(1)
public class PriorityOneGateway extends StripeGateway {
}
@@ -0,0 +1,7 @@
package com.ankurm.coredi.resolution;
import jakarta.annotation.Priority;
@Priority(2)
public class PriorityTwoGateway extends PaypalGateway {
}
@@ -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 {
}
@@ -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();
}
}
@@ -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 {
}
@@ -0,0 +1,9 @@
package com.ankurm.coredi.resolution;
public class StringHandler implements Handler<String> {
@Override
public String handle(String input) {
return "string:" + input;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.coredi.resolution;
import com.ankurm.coredi.injection.LoggingPaymentGateway;
public class StripeGateway extends LoggingPaymentGateway {
}
@@ -0,0 +1,5 @@
spring:
application:
name: core-di
main:
banner-mode: off
@@ -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<String> 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<Notifier> : 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<Notifier> 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<Notifier> order : %s", b.asList.stream().map(n -> n.getClass().getSimpleName()).toList());
t.line("Map<String,Notifier> : %s", b.asMap.keySet());
t.line("Set<Notifier> 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<Plugin> injection point when zero Plugin beans exist")) {
t.section("required List<Plugin>, 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<List<Plugin>> : isPresent=%s", opt.getBean(PluginHosts.WithOptional.class).plugins.isPresent());
var nul = Ctx.plain(PluginHosts.WithNullable.class);
t.line("@Nullable List<Plugin> : value=%s", nul.getBean(PluginHosts.WithNullable.class).plugins);
var prov = Ctx.plain(PluginHosts.WithProvider.class);
t.line("ObjectProvider<Plugin> -> 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<Handler<String>> : %s", c.stringHandlers.stream().map(h -> h.getClass().getSimpleName()).toList());
t.line("Handler<Integer> : %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")));
}
}
}
}
@@ -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();
}
}
}
@@ -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 <T> String attempt(Callable<T> 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();
}
}
}
}
@@ -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<? extends OrderService> 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);
}
}
}
}
@@ -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);
}
}