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
@@ -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);
}
}