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
@@ -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