Add cycles to core-beans: the circular-dependency failure report, what allow-circular-references repairs and hides, and four fixes on Boot 4.1

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
Claude
2026-09-24 06:29:14 +00:00
parent 51f3ecd5da
commit d6a2e1a5f2
61 changed files with 1545 additions and 8 deletions
+22 -2
View File
@@ -1,11 +1,12 @@
# core-beans # core-beans
Companion project for two articles on **[ankurm.com](https://ankurm.com)**. Companion project for three articles on **[ankurm.com](https://ankurm.com)**.
| Article | What it demonstrates | | Article | What it demonstrates |
|---|---| |---|---|
| [Spring Bean Scopes: Singleton, Prototype, Request, Session and the Prototype-in-Singleton Trap](https://ankurm.com/spring-bean-scopes-singleton-prototype-request-session-prototype-in-singleton-trap/) | instance counts for every scope, five ways to get a fresh prototype inside a singleton, a singleton that leaks one caller's data into another's, and request scope without a proxy failing at start-up | | [Spring Bean Scopes: Singleton, Prototype, Request, Session and the Prototype-in-Singleton Trap](https://ankurm.com/spring-bean-scopes-singleton-prototype-request-session-prototype-in-singleton-trap/) | instance counts for every scope, five ways to get a fresh prototype inside a singleton, a singleton that leaks one caller's data into another's, and request scope without a proxy failing at start-up |
| [Spring Bean Lifecycle in Boot 4: @PostConstruct, InitializingBean, SmartLifecycle and Shutdown Order](https://ankurm.com/spring-bean-lifecycle-postconstruct-smartlifecycle-shutdown-order/) | every callback in order from a real run, `@PostConstruct` running before the proxy exists, the non-static `BeanPostProcessor` warning, `SmartLifecycle` phases measured, and a request in flight during graceful shutdown with virtual threads | | [Spring Bean Lifecycle in Boot 4: @PostConstruct, InitializingBean, SmartLifecycle and Shutdown Order](https://ankurm.com/spring-bean-lifecycle-postconstruct-smartlifecycle-shutdown-order/) | every callback in order from a real run, `@PostConstruct` running before the proxy exists, the non-static `BeanPostProcessor` warning, `SmartLifecycle` phases measured, and a request in flight during graceful shutdown with virtual threads |
| [Circular Dependencies in Spring Boot 4: Why Startup Fails and 4 Ways to Fix It](https://ankurm.com/spring-boot-4-circular-dependencies-startup-fails-4-fixes/) | the start-up failure report for a three-service ring, the creation order behind it, what `allow-circular-references` does and does not repair (a half-built bean, `@Async` versus `@Cacheable`, an INFO line that swallows an exception), and four fixes run side by side: redesign, events, `@Lazy`, `ObjectProvider` |
Every console block, exception message and count quoted in those articles came out of `output/`, 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 and every file there is regenerated by one script. Most are written by the test suite, so if a
@@ -39,6 +40,7 @@ mvn test # runs the scenarios and rewrites the test-writte
| `web/` | `@RequestScope`, `@SessionScope`, `@ApplicationScope` beans and a diagnostic `/scopes` controller, plus the un-proxied request bean that fails at start-up | | `web/` | `@RequestScope`, `@SessionScope`, `@ApplicationScope` beans and a diagnostic `/scopes` controller, plus the un-proxied request bean that fails at start-up |
| `lifecycle/` | `KitchenSink` (every callback), the post-processor traps, `PhasedWorker` and `NeverStops` for `SmartLifecycle` | | `lifecycle/` | `KitchenSink` (every callback), the post-processor traps, `PhasedWorker` and `NeverStops` for `SmartLifecycle` |
| `shutdown/` | a web application with a `/slow` endpoint and two `SmartLifecycle` beans at different phases | | `shutdown/` | a web application with a `/slow` endpoint and two `SmartLifecycle` beans at different phases |
| `cycles/` | one package per scenario: `broken/` (the three-service ring), `allowed/` (field cycles with the flag on), `redesign/`, `events/`, `lazy/` and `provider/` (the four fixes), `beanmethods/` (a cycle written as `@Bean` methods) |
## Endpoints ## Endpoints
@@ -51,7 +53,7 @@ Both endpoints exist only to be called by the tests. They have no authorisation.
## Captured output ## Captured output
Files 01-17 and 19 (tests) and 18 (`capture-metadata.sh`). Timing rows assert coarse thresholds, not exact milliseconds; treat them as indicative. Files 01-17 and 19-35 (tests) and 18 (`capture-metadata.sh`). Timing rows assert coarse thresholds, not exact milliseconds; treat them as indicative.
| File | What it shows | | File | What it shows |
|---|---| |---|---|
@@ -74,6 +76,24 @@ Files 01-17 and 19 (tests) and 18 (`capture-metadata.sh`). Timing rows assert co
| [`17-worker-phase-vs-web-server.txt`](output/17-worker-phase-vs-web-server.txt) | SmartLifecycle beans with the default phase and with phase 1000, while a request is in flight | | [`17-worker-phase-vs-web-server.txt`](output/17-worker-phase-vs-web-server.txt) | SmartLifecycle beans with the default phase and with phase 1000, while a request is in flight |
| [`18-property-defaults.txt`](output/18-property-defaults.txt) | Property defaults read from spring-configuration-metadata.json (Boot 4.1.1 jars) | | [`18-property-defaults.txt`](output/18-property-defaults.txt) | Property defaults read from spring-configuration-metadata.json (Boot 4.1.1 jars) |
| [`19-bean-post-processors.txt`](output/19-bean-post-processors.txt) | The BeanPostProcessors registered in a plain Spring Boot context, in the order they run | | [`19-bean-post-processors.txt`](output/19-bean-post-processors.txt) | The BeanPostProcessors registered in a plain Spring Boot context, in the order they run |
| [`20-boot-cycle-failure-report.txt`](output/20-boot-cycle-failure-report.txt) | Three constructor-injected services in a ring: what Spring Boot prints when start-up fails |
| [`21-cycle-exception-and-creation-order.txt`](output/21-cycle-exception-and-creation-order.txt) | The exception behind the report, and the order in which the container started creating beans |
| [`22-bean-method-cycle-failure-report.txt`](output/22-bean-method-cycle-failure-report.txt) | A two-bean cycle written as `@Bean` methods |
| [`23-lazy-initialization-hides-the-cycle.txt`](output/23-lazy-initialization-hides-the-cycle.txt) | `spring.main.lazy-initialization=true` on the same ring: it starts, then fails on first use |
| [`24-allow-circular-half-built-bean.txt`](output/24-allow-circular-half-built-bean.txt) | A field cycle with the flag off and on: what each `@PostConstruct` sees |
| [`25-allow-circular-async-raw-reference.txt`](output/25-allow-circular-async-raw-reference.txt) | The same cycle with `@Async`: repaired silently at start-up, a raw-version error under lazy initialisation |
| [`26-allow-circular-cacheable-early-proxy.txt`](output/26-allow-circular-cacheable-early-proxy.txt) | The same cycle with `@Cacheable`: the injected reference is the proxy |
| [`27-fix-redesign.txt`](output/27-fix-redesign.txt) | Fix 1: extract the one thing the second bean needed |
| [`28-fix-events.txt`](output/28-fix-events.txt) | Fix 2: an event instead of a call, and what the publisher sees when the listener throws |
| [`29-fix-lazy.txt`](output/29-fix-lazy.txt) | Fix 3: `@Lazy` on the parameter, and the proxy class that arrives |
| [`30-fix-object-provider.txt`](output/30-fix-object-provider.txt) | Fix 4: `ObjectProvider`, resolving in the constructor, and a final class |
| [`31-lazy-on-a-final-class.txt`](output/31-lazy-on-a-final-class.txt) | `@Lazy` on a parameter whose type is a final class |
| [`32-lazy-injection-point-versus-lazy-bean.txt`](output/32-lazy-injection-point-versus-lazy-bean.txt) | `@Lazy` on the injection point versus on the bean, with a target that cannot be built |
| [`33-dispatcher-handler-cycle.txt`](output/33-dispatcher-handler-cycle.txt) | A cycle through `List<Handler>` and its `ObjectProvider` repair |
| [`34-depends-on-cycle.txt`](output/34-depends-on-cycle.txt) | `@DependsOn` in both directions: no injection involved |
| [`35-lazy-used-during-construction.txt`](output/35-lazy-used-during-construction.txt) | `@Lazy` on the parameter, but the bean calls the dependency from `@PostConstruct` |
| [`36-main-property-defaults.txt`](output/36-main-property-defaults.txt) | The defaults of `spring.main.allow-circular-references` and `spring.main.lazy-initialization`, from Boot's own metadata |
| [`37-early-reference-bytecode.txt`](output/37-early-reference-bytecode.txt) | Which post-processors hand out an early reference, and the `BeanCurrentlyInCreationException` handler in `preInstantiateSingleton` |
## Licence ## Licence
@@ -0,0 +1,22 @@
# Three constructor-injected services in a ring: what Spring Boot 4.1.1 prints when start-up fails
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| notificationService defined in file [<core-beans>/target/classes/com/ankurm/corebeans/cycles/broken/NotificationService.class]
↑ ↓
| orderService defined in file [<core-beans>/target/classes/com/ankurm/corebeans/cycles/broken/OrderService.class]
↑ ↓
| paymentService defined in file [<core-beans>/target/classes/com/ankurm/corebeans/cycles/broken/PaymentService.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,14 @@
# The exception behind the report, and the order in which the container started creating beans
--- exception classes, outermost first ---
UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException
--- root cause ---
org.springframework.beans.factory.BeanCurrentlyInCreationException
Error creating bean with name 'notificationService': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
--- creation order (one line per bean the container began to create) ---
start creating notificationService
start creating orderService
start creating paymentService
@@ -0,0 +1,20 @@
# A two-bean cycle written as @Bean methods: the report names the configuration class instead of a class file
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| clock defined in com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle
↑ ↓
| calendar defined in com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle
└─────┘
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,5 @@
# spring.main.lazy-initialization=true on the same three-service ring
context started: true
getBean(OrderService.class) failed: UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException
root cause: Error creating bean with name 'orderService': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
@@ -0,0 +1,15 @@
# A field-injected cycle with spring.main.allow-circular-references=true: what each @PostConstruct sees
--- flag off (the Boot default) ---
started: false
root cause: Error creating bean with name 'firstService': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
--- flag on ---
started: true
start creating firstService
FirstService constructed
start creating secondService
SecondService constructed
SecondService @PostConstruct sees first.isReady() = false
FirstService @PostConstruct done, ready=true
@@ -0,0 +1,21 @@
# The same field cycle, with @Async on AsyncFirst and the flag on: start-up versus a lazy first getBean
--- eager start-up (the default) ---
start creating com.ankurm.corebeans.cycles.allowed.AsyncFirst
start creating com.ankurm.corebeans.cycles.allowed.AsyncSecond
AsyncSecond constructed, identity 2f0ed952
AsyncSecond.setFirst on instance 2f0ed952 received a proxy: false
AsyncSecond constructed, identity 5a8816cc
AsyncSecond.setFirst on instance 5a8816cc received a proxy: true
started: true
AsyncFirst bean is a proxy : true
AsyncSecond.first is a proxy : true
AsyncSecond.first == the bean : true
log line from DefaultListableBeanFactory (INFO):
Bean 'com.ankurm.corebeans.cycles.allowed.AsyncFirst' marked for pre-instantiation (not lazy-init) but currently initialized by other thread - skipping it in mainline thread
--- spring.main.lazy-initialization=true, then getBean(AsyncFirst.class) ---
started: true
getBean(AsyncFirst.class) failed: BeanCurrentlyInCreationException
root cause: Error creating bean with name 'com.ankurm.corebeans.cycles.allowed.AsyncFirst': Bean with name 'com.ankurm.corebeans.cycles.allowed.AsyncFirst' has been injected into other beans [com.ankurm.corebeans.cycles.allowed.AsyncSecond] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesForType' with the 'allowEagerInit' flag turned off, for example.
@@ -0,0 +1,7 @@
# The same field cycle, with @Cacheable on one of the two beans, and the flag on
started: true
CachedFirst bean is a proxy : true (CachedFirst$$SpringCGLIB$$0)
CachedSecond.first is a proxy : true
CachedSecond.first == the bean : true
two calls through the injected reference return 1 and 1, method body ran 1 time(s)
+4
View File
@@ -0,0 +1,4 @@
# Fix 1: extract OrderCatalog, the one thing NotificationService needed
started: true
place("kettle") -> order placed: charged, e-mail says: 1 x kettle
+8
View File
@@ -0,0 +1,8 @@
# Fix 2: PaymentService publishes an event instead of calling NotificationService
started: true
place("kettle") -> order placed: charged
listener on thread main: e-mail says: 1 x kettle
--- what the publisher sees when the listener throws ---
place("boom") threw IllegalStateException: mail server down
+5
View File
@@ -0,0 +1,5 @@
# Fix 3: @Lazy on NotificationService's OrderService parameter
started: true
NotificationService holds a com.ankurm.corebeans.cycles.lazy.OrderService$$SpringCGLIB$$0
place("kettle") -> order placed: charged, e-mail says: 1 x kettle
@@ -0,0 +1,12 @@
# Fix 4: ObjectProvider<OrderService> in NotificationService
started: true
place("kettle") -> order placed: charged, e-mail says: 1 x kettle
--- the same provider, but getObject() is called inside the constructor ---
started: false
root cause: Error creating bean with name 'cycleFixesTest.EagerNotifier': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
--- the provider needs no proxy, so a final class is fine ---
started: true
hello() -> hello
@@ -0,0 +1,5 @@
# @Lazy on a constructor parameter whose type is a final class
started: false
exception classes: BeanCreationException -> AopConfigException -> IllegalArgumentException
root cause: java.lang.IllegalArgumentException: Cannot subclass final class com.ankurm.corebeans.CycleFixesTest$FinalTarget
@@ -0,0 +1,11 @@
# @Lazy on the injection point, with a target that cannot be built
--- @Lazy on the parameter, target is an ordinary singleton ---
started: false
root cause: cannot reach the payment provider
--- @Lazy on the parameter AND on the target bean ---
started: true
first call threw: BeanCreationException -> BeanInstantiationException -> IllegalStateException
root cause: cannot reach the payment provider
@@ -0,0 +1,11 @@
# Dispatcher(List<Handler>) and a Handler that needs the Dispatcher
--- Handler injects the Dispatcher ---
started: false
exception classes: UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException
root cause: Error creating bean with name 'hiddenCyclesTest.Dispatcher': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
--- Handler injects ObjectProvider<Dispatcher> ---
started: true
dispatch("ping") -> [seen ping (dispatcher available: true)]
@@ -0,0 +1,5 @@
# @DependsOn("b") on a and @DependsOn("a") on b: no injection involved at all
started: false
exception classes: BeanCreationException
root cause: Error creating bean with name 'b' defined in com.ankurm.corebeans.HiddenCyclesTest$DependsOnCycle: Circular depends-on relationship between 'b' and 'a'
@@ -0,0 +1,5 @@
# @Lazy on the parameter, but the bean calls the dependency from @PostConstruct
started: false
exception classes: BeanCreationException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException
root cause: Error creating bean with name 'cycleFixesTest.LazyUserA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
@@ -0,0 +1,4 @@
# Defaults read from spring-configuration-metadata.json in spring-boot-4.1.1.jar
spring.main.allow-circular-references default=False
spring.main.lazy-initialization default=False
@@ -0,0 +1,23 @@
# Who hands out an early reference, and the handler in preInstantiateSingleton (Spring Framework 7.0.9 jars)
--- the class that draws the report, in spring-boot-4.1.1.jar ---
org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzer$BeanInCycle.class
org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzer$DependencyCycle.class
org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzer.class
--- AbstractAutoProxyCreator (spring-aop): declared methods that mention 'early' ---
private final java.util.Map<java.lang.Object, java.lang.Object> earlyBeanReferences;
public java.lang.Object getEarlyBeanReference(java.lang.Object, java.lang.String);
--- AbstractAdvisingBeanPostProcessor (spring-aop): declared members that mention 'early' ---
matches: 0
--- the @Async post-processor's ancestry ---
class org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor extends org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor
class org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor extends org.springframework.aop.framework.AbstractAdvisingBeanPostProcessor
--- DefaultListableBeanFactory.preInstantiateSingleton(String, RootBeanDefinition): exception table ---
Exception table:
from to target type
157 162 165 Class org/springframework/beans/factory/BeanCurrentlyInCreationException
+1 -1
View File
@@ -15,7 +15,7 @@
<artifactId>core-beans</artifactId> <artifactId>core-beans</artifactId>
<version>1.0.0</version> <version>1.0.0</version>
<name>core-beans</name> <name>core-beans</name>
<description>Spring bean scopes and the bean lifecycle in Spring Boot 4</description> <description>Spring bean scopes, the bean lifecycle and circular dependencies in Spring Boot 4</description>
<properties> <properties>
<java.version>25</java.version> <java.version>25</java.version>
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Two facts about the circular-reference machinery, read out of the Framework and Boot jars rather than from docs:
# 36 the defaults of spring.main.allow-circular-references and spring.main.lazy-initialization
# 37 which post-processors supply an early reference, and the exception handler in preInstantiateSingleton
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output
M2=~/.m2/repository/org/springframework
BOOT=$M2/boot/spring-boot/4.1.1/spring-boot-4.1.1.jar
AOP=$M2/spring-aop/7.0.9/spring-aop-7.0.9.jar
CTX=$M2/spring-context/7.0.9/spring-context-7.0.9.jar
BEANS=$M2/spring-beans/7.0.9/spring-beans-7.0.9.jar
JP() { javap "$@" 2>&1 | grep -v '^Picked up'; }
{
echo "# Defaults read from spring-configuration-metadata.json in spring-boot-4.1.1.jar"
echo
unzip -p "$BOOT" META-INF/spring-configuration-metadata.json | python3 -c '
import json,sys
d=json.load(sys.stdin)
for p in d["properties"]:
if p["name"] in ("spring.main.allow-circular-references","spring.main.lazy-initialization"):
print("%-40s default=%s" % (p["name"], p.get("defaultValue")))
'
} > output/36-main-property-defaults.txt
{
echo "# Who hands out an early reference, and the handler in preInstantiateSingleton (Spring Framework 7.0.9 jars)"
echo
echo "--- the class that draws the report, in spring-boot-4.1.1.jar ---"
unzip -l "$BOOT" | awk '/BeanCurrentlyInCreationFailureAnalyzer/ {print $4}'
echo
echo "--- AbstractAutoProxyCreator (spring-aop): declared methods that mention 'early' ---"
JP -p -cp "$AOP" org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator | grep -i early | sed 's/^ *//'
echo
echo "--- AbstractAdvisingBeanPostProcessor (spring-aop): declared members that mention 'early' ---"
n=$(JP -p -cp "$AOP" org.springframework.aop.framework.AbstractAdvisingBeanPostProcessor | grep -ci early || true)
echo "matches: $n"
echo
echo "--- the @Async post-processor's ancestry ---"
JP -cp "$CTX:$AOP" org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor | grep -o 'class [^ ]* extends [^ ]*'
JP -cp "$AOP" org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor | grep -o 'class [^ ]* extends [^ ]*'
echo
echo "--- DefaultListableBeanFactory.preInstantiateSingleton(String, RootBeanDefinition): exception table ---"
JP -p -c -cp "$BEANS" org.springframework.beans.factory.support.DefaultListableBeanFactory \
| awk '/private java.util.concurrent.CompletableFuture<\?> preInstantiateSingleton\(/ {f=1} f&&/Exception table:/ {t=1} t {print} t&&/^ private void instantiateSingletonInBackgroundThread/ {exit}' \
| grep -v 'instantiateSingletonInBackgroundThread' | sed 's/^ *//'
} > output/37-early-reference-bytecode.txt
cat output/36-main-property-defaults.txt output/37-early-reference-bytecode.txt
+6 -3
View File
@@ -3,18 +3,21 @@
# #
# ./scripts/run-all.sh # ./scripts/run-all.sh
# #
# Needs a JDK 25 and Maven 3.9. Transcripts 01-17 and 19 come out of the test suite, which is the point: # Needs a JDK 25 and Maven 3.9. Transcripts 01-17 and 19-35 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. # the figures in the articles are assertions that fail the build if they stop being true.
# Timing-based rows (12, 16) assert coarse thresholds, not exact milliseconds. # Timing-based rows (12, 16) assert coarse thresholds, not exact milliseconds.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
echo "== test suite (transcripts 01-17)" echo "== test suite (transcripts 01-17 and 19-35)"
mvn -B test mvn -B test
echo "== property defaults read from Boot's metadata (18)" echo "== property defaults read from Boot's metadata (18)"
./scripts/capture-metadata.sh ./scripts/capture-metadata.sh
echo "== early-reference and property facts read from the jars (36-37)"
./scripts/capture-cycle-facts.sh
echo echo
echo "output:" echo "output:"
ls -1 output ls -1 output
@@ -0,0 +1,11 @@
package com.ankurm.corebeans.cycles.allowed;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan(basePackageClasses = AllowedConfig.class,
excludeFilters = @org.springframework.context.annotation.ComponentScan.Filter(
type = org.springframework.context.annotation.FilterType.REGEX, pattern = ".*(Async|Cached).*"))
public class AllowedConfig {
}
@@ -0,0 +1,11 @@
package com.ankurm.corebeans.cycles.allowed;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
@Import({AsyncFirst.class, AsyncSecond.class})
public class AsyncConfig {
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.allowed;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/** A field cycle, with one bean carrying @Async. */
@Service
public class AsyncFirst {
@Autowired
AsyncSecond second;
@Async
public void work() {
}
}
@@ -0,0 +1,26 @@
package com.ankurm.corebeans.cycles.allowed;
import com.ankurm.corebeans.Trace;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AsyncSecond {
AsyncFirst first;
public AsyncSecond() {
Trace.log("AsyncSecond constructed, identity " + Integer.toHexString(System.identityHashCode(this)));
}
@Autowired
void setFirst(AsyncFirst first) {
Trace.log("AsyncSecond.setFirst on instance " + Integer.toHexString(System.identityHashCode(this)) + " received a proxy: " + AopUtils.isAopProxy(first));
this.first = first;
}
public AsyncFirst first() {
return first;
}
}
@@ -0,0 +1,19 @@
package com.ankurm.corebeans.cycles.allowed;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration
@EnableCaching
@Import({CachedFirst.class, CachedSecond.class})
public class CachedConfig {
@Bean
CacheManager cacheManager() {
return new ConcurrentMapCacheManager("answers");
}
}
@@ -0,0 +1,25 @@
package com.ankurm.corebeans.cycles.allowed;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
/** The same field cycle as AsyncFirst, but the proxying comes from @Cacheable instead of @Async. */
@Service
public class CachedFirst {
static final AtomicInteger CALLS = new AtomicInteger();
@Autowired
CachedSecond second;
public static int calls() {
return CALLS.get();
}
@Cacheable("answers")
public int answer() {
return CALLS.incrementAndGet();
}
}
@@ -0,0 +1,15 @@
package com.ankurm.corebeans.cycles.allowed;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class CachedSecond {
@Autowired
CachedFirst first;
public CachedFirst first() {
return first;
}
}
@@ -0,0 +1,29 @@
package com.ankurm.corebeans.cycles.allowed;
import com.ankurm.corebeans.Trace;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class FirstService {
@Autowired
SecondService second;
boolean ready;
public FirstService() {
Trace.log("FirstService constructed");
}
@PostConstruct
void init() {
ready = true;
Trace.log("FirstService @PostConstruct done, ready=true");
}
public boolean isReady() {
return ready;
}
}
@@ -0,0 +1,22 @@
package com.ankurm.corebeans.cycles.allowed;
import com.ankurm.corebeans.Trace;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class SecondService {
@Autowired
FirstService first;
public SecondService() {
Trace.log("SecondService constructed");
}
@PostConstruct
void init() {
Trace.log("SecondService @PostConstruct sees first.isReady() = " + first.isReady());
}
}
@@ -0,0 +1,25 @@
package com.ankurm.corebeans.cycles.beanmethods;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** The same cycle written as @Bean methods: each method asks for the other bean as a parameter. */
@Configuration
public class BeanMethodCycle {
public record Clock(Calendar calendar) {
}
public record Calendar(Clock clock) {
}
@Bean
Clock clock(Calendar calendar) {
return new Clock(calendar);
}
@Bean
Calendar calendar(Clock clock) {
return new Calendar(clock);
}
}
@@ -0,0 +1,10 @@
package com.ankurm.corebeans.cycles.broken;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
/** Scans only this package: OrderService -> PaymentService -> NotificationService -> OrderService. */
@Configuration
@ComponentScan
public class BrokenConfig {
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.broken;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final OrderService orders;
public NotificationService(OrderService orders) {
this.orders = orders;
}
public String confirm(String sku) {
return "e-mail says: " + orders.describe(sku);
}
}
@@ -0,0 +1,21 @@
package com.ankurm.corebeans.cycles.broken;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final PaymentService payments;
public OrderService(PaymentService payments) {
this.payments = payments;
}
public String place(String sku) {
return "order placed: " + payments.charge(sku);
}
public String describe(String sku) {
return "1 x " + sku;
}
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.broken;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final NotificationService notifications;
public PaymentService(NotificationService notifications) {
this.notifications = notifications;
}
public String charge(String sku) {
return "charged, " + notifications.confirm(sku);
}
}
@@ -0,0 +1,9 @@
package com.ankurm.corebeans.cycles.events;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
public class EventsConfig {
}
@@ -0,0 +1,24 @@
package com.ankurm.corebeans.cycles.events;
import com.ankurm.corebeans.Trace;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final OrderService orders;
public NotificationService(OrderService orders) {
this.orders = orders;
}
@EventListener
void onPaymentCaptured(PaymentCaptured event) {
if (event.sku().equals("boom")) {
throw new IllegalStateException("mail server down");
}
Trace.log("listener on thread " + Thread.currentThread().getName()
+ ": e-mail says: " + orders.describe(event.sku()));
}
}
@@ -0,0 +1,21 @@
package com.ankurm.corebeans.cycles.events;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final PaymentService payments;
public OrderService(PaymentService payments) {
this.payments = payments;
}
public String place(String sku) {
return "order placed: " + payments.charge(sku);
}
public String describe(String sku) {
return "1 x " + sku;
}
}
@@ -0,0 +1,5 @@
package com.ankurm.corebeans.cycles.events;
/** A plain record: since Spring 4.2 an event does not have to extend ApplicationEvent. */
public record PaymentCaptured(String sku) {
}
@@ -0,0 +1,19 @@
package com.ankurm.corebeans.cycles.events;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final ApplicationEventPublisher events;
public PaymentService(ApplicationEventPublisher events) {
this.events = events;
}
public String charge(String sku) {
events.publishEvent(new PaymentCaptured(sku));
return "charged";
}
}
@@ -0,0 +1,9 @@
package com.ankurm.corebeans.cycles.lazy;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
public class LazyConfig {
}
@@ -0,0 +1,23 @@
package com.ankurm.corebeans.cycles.lazy;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final OrderService orders;
/** @Lazy makes Spring inject a proxy and look the real OrderService up on the first call. */
public NotificationService(@Lazy OrderService orders) {
this.orders = orders;
}
public String confirm(String sku) {
return "e-mail says: " + orders.describe(sku);
}
public String injectedType() {
return orders.getClass().getName();
}
}
@@ -0,0 +1,21 @@
package com.ankurm.corebeans.cycles.lazy;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final PaymentService payments;
public OrderService(PaymentService payments) {
this.payments = payments;
}
public String place(String sku) {
return "order placed: " + payments.charge(sku);
}
public String describe(String sku) {
return "1 x " + sku;
}
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.lazy;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final NotificationService notifications;
public PaymentService(NotificationService notifications) {
this.notifications = notifications;
}
public String charge(String sku) {
return "charged, " + notifications.confirm(sku);
}
}
@@ -0,0 +1,19 @@
package com.ankurm.corebeans.cycles.provider;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final ObjectProvider<OrderService> orders;
/** Nothing is resolved here: the provider is a handle, not the bean. */
public NotificationService(ObjectProvider<OrderService> orders) {
this.orders = orders;
}
public String confirm(String sku) {
return "e-mail says: " + orders.getObject().describe(sku);
}
}
@@ -0,0 +1,21 @@
package com.ankurm.corebeans.cycles.provider;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final PaymentService payments;
public OrderService(PaymentService payments) {
this.payments = payments;
}
public String place(String sku) {
return "order placed: " + payments.charge(sku);
}
public String describe(String sku) {
return "1 x " + sku;
}
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.provider;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final NotificationService notifications;
public PaymentService(NotificationService notifications) {
this.notifications = notifications;
}
public String charge(String sku) {
return "charged, " + notifications.confirm(sku);
}
}
@@ -0,0 +1,9 @@
package com.ankurm.corebeans.cycles.provider;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
public class ProviderConfig {
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.redesign;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final OrderCatalog catalog;
public NotificationService(OrderCatalog catalog) {
this.catalog = catalog;
}
public String confirm(String sku) {
return "e-mail says: " + catalog.describe(sku);
}
}
@@ -0,0 +1,12 @@
package com.ankurm.corebeans.cycles.redesign;
import org.springframework.stereotype.Service;
/** The one thing NotificationService actually needed from OrderService, pulled out into its own bean. */
@Service
public class OrderCatalog {
public String describe(String sku) {
return "1 x " + sku;
}
}
@@ -0,0 +1,23 @@
package com.ankurm.corebeans.cycles.redesign;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final PaymentService payments;
private final OrderCatalog catalog;
public OrderService(PaymentService payments, OrderCatalog catalog) {
this.payments = payments;
this.catalog = catalog;
}
public String place(String sku) {
return "order placed: " + payments.charge(sku);
}
public String describe(String sku) {
return catalog.describe(sku);
}
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.redesign;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final NotificationService notifications;
public PaymentService(NotificationService notifications) {
this.notifications = notifications;
}
public String charge(String sku) {
return "charged, " + notifications.confirm(sku);
}
}
@@ -0,0 +1,9 @@
package com.ankurm.corebeans.cycles.redesign;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
public class RedesignConfig {
}
@@ -0,0 +1,21 @@
package com.ankurm.corebeans.cycles.support;
import com.ankurm.corebeans.Trace;
import org.springframework.beans.factory.config.InstantiationAwareBeanPostProcessor;
/**
* Logs the moment the container starts creating each demo bean. It is how the creation
* chain (orderService, then paymentService, then notificationService) becomes visible.
*/
public class CreationTracer implements InstantiationAwareBeanPostProcessor {
@Override
public Object postProcessBeforeInstantiation(Class<?> beanClass, String beanName) {
String simple = beanClass.getSimpleName();
if (beanClass.getPackageName().startsWith("com.ankurm.corebeans.cycles")
&& !simple.endsWith("Config") && !simple.endsWith("Tracer")) {
Trace.log("start creating " + beanName);
}
return null;
}
}
@@ -0,0 +1,123 @@
package com.ankurm.corebeans;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.corebeans.cycles.allowed.AllowedConfig;
import com.ankurm.corebeans.cycles.allowed.AsyncConfig;
import com.ankurm.corebeans.cycles.allowed.AsyncFirst;
import com.ankurm.corebeans.cycles.allowed.AsyncSecond;
import com.ankurm.corebeans.cycles.allowed.CachedConfig;
import com.ankurm.corebeans.cycles.allowed.CachedFirst;
import com.ankurm.corebeans.cycles.allowed.CachedSecond;
import com.ankurm.corebeans.cycles.support.CreationTracer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.aop.support.AopUtils;
/** Post 20: what spring.main.allow-circular-references=true really buys, and what it costs. */
@ExtendWith(OutputCaptureExtension.class)
class AllowCircularTest {
private static final String[] ALLOW = {"spring.main.allow-circular-references=true"};
@BeforeEach
void reset() {
Trace.drain();
((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME))
.setLevel(ch.qos.logback.classic.Level.INFO);
}
@Test
void theFlagMakesAFieldCycleStartButOneBeanIsHalfBuilt() {
try (var t = new Transcript("24-allow-circular-half-built-bean.txt",
"A field-injected cycle with spring.main.allow-circular-references=true: what each @PostConstruct sees")) {
t.section("flag off (the Boot default)");
var off = BootRun.run(new String[0], AllowedConfig.class);
t.line("started: %s", off.started());
t.line("root cause: %s", BootRun.root(off.failure()).getMessage());
assertThat(off.started()).isFalse();
Trace.drain();
t.section("flag on");
var on = BootRun.run(ALLOW, AllowedConfig.class, CreationTracer.class);
t.line("started: %s", on.started());
assertThat(on.started()).isTrue();
Trace.drain().forEach(e -> t.line("%s", e));
on.close();
}
}
@Test
void asyncInTheCycleIsRepairedSilentlyAtStartupAndFailsOnAnyOtherPath(CapturedOutput output) {
try (var t = new Transcript("25-allow-circular-async-raw-reference.txt",
"The same field cycle, with @Async on AsyncFirst and the flag on: start-up versus a lazy first getBean")) {
t.section("eager start-up (the default)");
var eager = BootRun.run(new String[] {"spring.main.allow-circular-references=true", "logging.level.root=INFO"},
AsyncConfig.class, CreationTracer.class);
Trace.drain().forEach(e -> t.line("%s", e));
t.line("started: %s", eager.started());
describe(t, eager);
eager.close();
var infoLines = java.util.Arrays.stream(output.getAll().split("\n"))
.filter(l -> l.contains("DefaultListableBeanFactory")).map(l -> l.substring(l.indexOf(" : ") + 3)).toList();
t.line("log line from DefaultListableBeanFactory (INFO):");
infoLines.forEach(l -> t.line(" %s", l));
assertThat(eager.started()).isTrue();
assertThat(infoLines).isNotEmpty();
t.section("spring.main.lazy-initialization=true, then getBean(AsyncFirst.class)");
var lazy = BootRun.run(new String[] {"spring.main.allow-circular-references=true", "spring.main.lazy-initialization=true"},
AsyncConfig.class);
t.line("started: %s", lazy.started());
assertThat(lazy.started()).isTrue();
try {
lazy.context().getBean(AsyncFirst.class);
t.line("getBean(AsyncFirst.class): returned a bean");
} catch (RuntimeException e) {
t.line("getBean(AsyncFirst.class) failed: %s", BootRun.chain(e));
t.line("root cause: %s", BootRun.root(e).getMessage());
assertThat(BootRun.root(e).getMessage()).contains("in its raw version");
} finally {
lazy.close();
}
}
}
private static void describe(Transcript t, BootRun.Result result) {
if (!result.started()) {
return;
}
var first = result.context().getBean(AsyncFirst.class);
var second = result.context().getBean(AsyncSecond.class);
t.line("AsyncFirst bean is a proxy : %s", AopUtils.isAopProxy(first));
t.line("AsyncSecond.first is a proxy : %s", AopUtils.isAopProxy(second.first()));
t.line("AsyncSecond.first == the bean : %s", second.first() == first);
}
@Test
void cacheableInTheCycleWorksBecauseTheProxyCreatorHandsOutAnEarlyProxy() {
try (var t = new Transcript("26-allow-circular-cacheable-early-proxy.txt",
"The same field cycle, with @Cacheable on one of the two beans, and the flag on")) {
var result = BootRun.run(ALLOW, CachedConfig.class);
t.line("started: %s", result.started());
assertThat(result.started()).isTrue();
var ctx = result.context();
var first = ctx.getBean(CachedFirst.class);
var second = ctx.getBean(CachedSecond.class);
t.line("CachedFirst bean is a proxy : %s (%s)", AopUtils.isAopProxy(first), first.getClass().getSimpleName());
t.line("CachedSecond.first is a proxy : %s", AopUtils.isAopProxy(second.first()));
t.line("CachedSecond.first == the bean : %s", second.first() == first);
int a = second.first().answer();
int b = second.first().answer();
t.line("two calls through the injected reference return %d and %d, method body ran %d time(s)",
a, b, CachedFirst.calls());
assertThat(AopUtils.isAopProxy(second.first())).isTrue();
assertThat(second.first()).isSameAs(first);
assertThat(CachedFirst.calls()).isEqualTo(1);
result.close();
}
}
}
@@ -0,0 +1,60 @@
package com.ankurm.corebeans;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
/** Starts a Spring Boot context without a web server and returns either the context or the failure. */
public final class BootRun {
private BootRun() {
}
public static Result run(String[] extraProperties, Class<?>... sources) {
var props = new java.util.ArrayList<String>();
props.add("spring.main.banner-mode=off");
props.add("logging.level.root=OFF");
props.addAll(java.util.List.of(extraProperties));
try {
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(sources)
.web(WebApplicationType.NONE)
.properties(props.toArray(String[]::new))
.run();
return new Result(ctx, null);
} catch (RuntimeException e) {
return new Result(null, e);
}
}
/** Names the exception classes from the outside in: what a stack trace's "Caused by:" lines say, minus the noise. */
public static String chain(Throwable t) {
var names = new java.util.ArrayList<String>();
for (Throwable c = t; c != null && !names.contains(c.getClass().getSimpleName() + c.hashCode()); c = c.getCause()) {
names.add(c.getClass().getSimpleName());
if (c.getCause() == c) {
break;
}
}
return String.join(" -> ", names);
}
public static Throwable root(Throwable t) {
while (t.getCause() != null && t.getCause() != t) {
t = t.getCause();
}
return t;
}
public record Result(ConfigurableApplicationContext context, RuntimeException failure) {
public boolean started() {
return failure == null;
}
public void close() {
if (context != null) {
context.close();
}
}
}
}
@@ -0,0 +1,119 @@
package com.ankurm.corebeans;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle;
import com.ankurm.corebeans.cycles.broken.BrokenConfig;
import com.ankurm.corebeans.cycles.broken.OrderService;
import com.ankurm.corebeans.cycles.support.CreationTracer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
/** Post 20: the start-up failure itself, read line by line, and what the container was doing when it hit it. */
@ExtendWith(OutputCaptureExtension.class)
class CycleFailureTest {
@BeforeEach
void reset() {
Trace.drain();
((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME))
.setLevel(ch.qos.logback.classic.Level.INFO);
}
/** From the row of asterisks to the end of the "Action:" paragraph. */
static String report(String console) {
int start = console.indexOf("***************************\nAPPLICATION FAILED TO START");
assertThat(start).as("Boot printed a failure report").isGreaterThanOrEqualTo(0);
String tail = console.substring(start);
int action = tail.indexOf("Action:");
int end = tail.indexOf("\n\n", action + "Action:\n\n".length());
return (end < 0 ? tail : tail.substring(0, end)).stripTrailing();
}
@Test
void bootPrintsTheCycleAsAPicture(CapturedOutput output) {
try (var t = new Transcript("20-boot-cycle-failure-report.txt",
"Three constructor-injected services in a ring: what Spring Boot 4.1.1 prints when start-up fails")) {
var thrown = new java.util.concurrent.atomic.AtomicReference<Throwable>();
try {
new SpringApplicationBuilder(BrokenConfig.class)
.web(WebApplicationType.NONE)
.properties("logging.level.root=INFO", "spring.main.banner-mode=off")
.run();
} catch (RuntimeException e) {
thrown.set(e);
}
assertThat(thrown.get()).isNotNull();
String report = report(output.getAll());
t.line("%s", report);
assertThat(report).contains("The dependencies of some of the beans in the application context form a cycle:")
.contains("┌─────┐").contains("└─────┘")
.contains("orderService").contains("paymentService").contains("notificationService")
.contains("spring.main.allow-circular-references");
}
}
@Test
void theExceptionUnderneathAndTheCreationOrderBehindIt() {
try (var t = new Transcript("21-cycle-exception-and-creation-order.txt",
"The exception behind the report, and the order in which the container started creating beans")) {
var result = BootRun.run(new String[0], BrokenConfig.class, CreationTracer.class);
assertThat(result.started()).isFalse();
t.section("exception classes, outermost first");
t.line("%s", BootRun.chain(result.failure()));
t.section("root cause");
t.line("%s", BootRun.root(result.failure()).getClass().getName());
t.line("%s", BootRun.root(result.failure()).getMessage());
t.section("creation order (one line per bean the container began to create)");
var events = Trace.drain();
events.forEach(e -> t.line("%s", e));
assertThat(events).containsExactly(
"start creating notificationService", "start creating orderService", "start creating paymentService");
assertThat(BootRun.root(result.failure()).getMessage())
.contains("Requested bean is currently in creation").contains("notificationService");
}
}
@Test
void theSameCycleWrittenAsBeanMethods(CapturedOutput output) {
try (var t = new Transcript("22-bean-method-cycle-failure-report.txt",
"A two-bean cycle written as @Bean methods: the report names the configuration class instead of a class file")) {
try {
new SpringApplicationBuilder(BeanMethodCycle.class)
.web(WebApplicationType.NONE)
.properties("logging.level.root=INFO", "spring.main.banner-mode=off")
.run();
} catch (RuntimeException expected) {
// the report is what we came for
}
String report = report(output.getAll());
t.line("%s", report);
assertThat(report).contains("clock defined in com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle");
}
}
@Test
void lazyInitialisationMovesTheFailureToTheFirstUse() {
try (var t = new Transcript("23-lazy-initialization-hides-the-cycle.txt",
"spring.main.lazy-initialization=true on the same three-service ring")) {
var result = BootRun.run(new String[] {"spring.main.lazy-initialization=true"}, BrokenConfig.class);
t.line("context started: %s", result.started());
assertThat(result.started()).isTrue();
try {
result.context().getBean(OrderService.class);
t.line("getBean(OrderService.class): returned a bean");
} catch (RuntimeException e) {
t.line("getBean(OrderService.class) failed: %s", BootRun.chain(e));
t.line("root cause: %s", BootRun.root(e).getMessage());
assertThat(BootRun.root(e).getMessage()).contains("currently in creation");
} finally {
result.close();
}
}
}
}
@@ -0,0 +1,265 @@
package com.ankurm.corebeans;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.ankurm.corebeans.cycles.events.EventsConfig;
import com.ankurm.corebeans.cycles.lazy.LazyConfig;
import com.ankurm.corebeans.cycles.provider.ProviderConfig;
import com.ankurm.corebeans.cycles.redesign.RedesignConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
/** Post 20: the four fixes, each starting the same three-service application. */
class CycleFixesTest {
@BeforeEach
void reset() {
Trace.drain();
}
@Test
void fixOneRedesign() {
try (var t = new Transcript("27-fix-redesign.txt", "Fix 1: extract OrderCatalog, the one thing NotificationService needed")) {
var result = BootRun.run(new String[0], RedesignConfig.class);
t.line("started: %s", result.started());
assertThat(result.started()).isTrue();
var orders = result.context().getBean(com.ankurm.corebeans.cycles.redesign.OrderService.class);
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
assertThat(orders.place("kettle")).contains("1 x kettle");
result.close();
}
}
@Test
void fixTwoEvents() {
try (var t = new Transcript("28-fix-events.txt", "Fix 2: PaymentService publishes an event instead of calling NotificationService")) {
var result = BootRun.run(new String[0], EventsConfig.class);
t.line("started: %s", result.started());
assertThat(result.started()).isTrue();
var orders = result.context().getBean(com.ankurm.corebeans.cycles.events.OrderService.class);
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
Trace.drain().forEach(e -> t.line("%s", e));
t.section("what the publisher sees when the listener throws");
try {
orders.place("boom");
t.line("place(\"boom\") returned normally");
} catch (RuntimeException e) {
t.line("place(\"boom\") threw %s: %s", e.getClass().getSimpleName(), e.getMessage());
assertThat(e).hasMessage("mail server down");
}
result.close();
}
}
@Test
void fixThreeLazy() {
try (var t = new Transcript("29-fix-lazy.txt", "Fix 3: @Lazy on NotificationService's OrderService parameter")) {
var result = BootRun.run(new String[0], LazyConfig.class);
t.line("started: %s", result.started());
assertThat(result.started()).isTrue();
var ctx = result.context();
var orders = ctx.getBean(com.ankurm.corebeans.cycles.lazy.OrderService.class);
var notifications = ctx.getBean(com.ankurm.corebeans.cycles.lazy.NotificationService.class);
t.line("NotificationService holds a %s", notifications.injectedType());
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
assertThat(notifications.injectedType()).contains("SpringCGLIB");
result.close();
}
}
@Test
void fixFourObjectProvider() {
try (var t = new Transcript("30-fix-object-provider.txt", "Fix 4: ObjectProvider<OrderService> in NotificationService")) {
var result = BootRun.run(new String[0], ProviderConfig.class);
t.line("started: %s", result.started());
assertThat(result.started()).isTrue();
var orders = result.context().getBean(com.ankurm.corebeans.cycles.provider.OrderService.class);
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
result.close();
t.section("the same provider, but getObject() is called inside the constructor");
var eager = Ctx.tryStart(EagerNotifier.class, EagerOrders.class);
t.line("started: %s", eager.started());
assertThat(eager.started()).isFalse();
t.line("root cause: %s", Ctx.root(eager.failure()).getMessage());
t.section("the provider needs no proxy, so a final class is fine");
var finalOutcome = Ctx.tryStart(FinalProviderConsumer.class, FinalTarget.class);
t.line("started: %s", finalOutcome.started());
assertThat(finalOutcome.started()).isTrue();
t.line("hello() -> %s", finalOutcome.context().getBean(FinalProviderConsumer.class).hello());
finalOutcome.closeQuietly();
}
}
@Component
static class FinalProviderConsumer {
private final ObjectProvider<FinalTarget> target;
FinalProviderConsumer(ObjectProvider<FinalTarget> target) {
this.target = target;
}
String hello() {
return target.getObject().hello();
}
}
// ---- the provider trap: resolving in the constructor rebuilds the cycle -------------------------------
@Component
static class EagerOrders {
EagerOrders(EagerNotifier notifier) {
}
}
@Component
static class EagerNotifier {
EagerNotifier(ObjectProvider<EagerOrders> orders) {
orders.getObject();
}
}
// ---- the two ways @Lazy fails later ---------------------------------------------------------------------
static final class FinalTarget {
String hello() {
return "hello";
}
}
@Component
static class FinalConsumer {
FinalConsumer(@Lazy FinalTarget target) {
}
}
/** A singleton like any other: the context builds it at start-up whether or not anyone injected it lazily. */
@Component
static class Flaky {
Flaky() {
throw new IllegalStateException("cannot reach the payment provider");
}
String hello() {
return "hello";
}
}
/** The same class, but the bean itself is lazy. */
@Component
@Lazy
static class LazyFlaky {
LazyFlaky() {
throw new IllegalStateException("cannot reach the payment provider");
}
String hello() {
return "hello";
}
}
@Component
static class FlakyConsumer {
private final Flaky flaky;
FlakyConsumer(@Lazy Flaky flaky) {
this.flaky = flaky;
}
String call() {
return flaky.hello();
}
}
@Component
static class LazyFlakyConsumer {
private final LazyFlaky flaky;
LazyFlakyConsumer(@Lazy LazyFlaky flaky) {
this.flaky = flaky;
}
String call() {
return flaky.hello();
}
}
@Component
static class LazyUserA {
private final LazyUserB b;
LazyUserA(@Lazy LazyUserB b) {
this.b = b;
}
@jakarta.annotation.PostConstruct
void init() {
b.hello();
}
}
@Component
static class LazyUserB {
LazyUserB(LazyUserA a) {
}
String hello() {
return "hello";
}
}
@Test
void lazyProxyResolvedDuringConstructionRebuildsTheCycle() {
try (var t = new Transcript("35-lazy-used-during-construction.txt",
"@Lazy on the parameter, but the bean calls the dependency from @PostConstruct")) {
var outcome = BootRun.run(new String[0], LazyUserA.class, LazyUserB.class);
t.line("started: %s", outcome.started());
assertThat(outcome.started()).isFalse();
t.line("exception classes: %s", BootRun.chain(outcome.failure()));
t.line("root cause: %s", BootRun.root(outcome.failure()).getMessage());
}
}
@Test
void lazyOnAFinalClass() {
try (var t = new Transcript("31-lazy-on-a-final-class.txt", "@Lazy on a constructor parameter whose type is a final class")) {
var outcome = Ctx.tryStart(FinalConsumer.class, FinalTarget.class);
t.line("started: %s", outcome.started());
if (!outcome.started()) {
t.line("exception classes: %s", BootRun.chain(outcome.failure()));
t.line("root cause: %s: %s", Ctx.root(outcome.failure()).getClass().getName(), Ctx.root(outcome.failure()).getMessage());
} else {
outcome.closeQuietly();
}
assertThat(outcome.started()).isFalse();
}
}
@Test
void lazyDelaysOnlyWhatTheBeanItselfDelays() {
try (var t = new Transcript("32-lazy-injection-point-versus-lazy-bean.txt",
"@Lazy on the injection point, with a target that cannot be built")) {
t.section("@Lazy on the parameter, target is an ordinary singleton");
var eager = Ctx.tryStart(FlakyConsumer.class, Flaky.class);
t.line("started: %s", eager.started());
assertThat(eager.started()).isFalse();
t.line("root cause: %s", Ctx.root(eager.failure()).getMessage());
t.section("@Lazy on the parameter AND on the target bean");
var lazy = Ctx.tryStart(LazyFlakyConsumer.class, LazyFlaky.class);
t.line("started: %s", lazy.started());
assertThat(lazy.started()).isTrue();
var consumer = lazy.context().getBean(LazyFlakyConsumer.class);
assertThatThrownBy(consumer::call).satisfies(e -> {
t.line("first call threw: %s", BootRun.chain(e));
t.line("root cause: %s", Ctx.root(e).getMessage());
});
lazy.closeQuietly();
}
}
}
@@ -0,0 +1,121 @@
package com.ankurm.corebeans;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Component;
/** Post 20: cycles that do not look like A -> B -> A when you read the code. */
class HiddenCyclesTest {
interface Handler {
String handle(String message);
}
/** Collects every Handler. Looks harmless. */
@Component
static class Dispatcher {
private final List<Handler> handlers;
Dispatcher(List<Handler> handlers) {
this.handlers = handlers;
}
String dispatch(String message) {
return handlers.stream().map(h -> h.handle(message)).toList().toString();
}
}
/** Wants to re-dispatch, so it asks for the Dispatcher. That is the whole cycle. */
@Component
static class RedispatchingHandler implements Handler {
private final Dispatcher dispatcher;
RedispatchingHandler(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
@Override
public String handle(String message) {
return "seen " + message;
}
}
/** The repair: hold a handle, not the bean. */
@Component
static class LazyLookupHandler implements Handler {
private final ObjectProvider<LookupDispatcher> dispatcher;
LazyLookupHandler(ObjectProvider<LookupDispatcher> dispatcher) {
this.dispatcher = dispatcher;
}
@Override
public String handle(String message) {
return "seen " + message + " (dispatcher available: " + (dispatcher.getIfAvailable() != null) + ")";
}
}
@Component
static class LookupDispatcher {
private final List<Handler> handlers;
LookupDispatcher(List<Handler> handlers) {
this.handlers = handlers;
}
String dispatch(String message) {
return handlers.stream().map(h -> h.handle(message)).toList().toString();
}
}
@Test
void aCycleThroughAListOfCollaborators() {
try (var t = new Transcript("33-dispatcher-handler-cycle.txt", "Dispatcher(List<Handler>) and a Handler that needs the Dispatcher")) {
t.section("Handler injects the Dispatcher");
var broken = Ctx.tryStart(Dispatcher.class, RedispatchingHandler.class);
t.line("started: %s", broken.started());
assertThat(broken.started()).isFalse();
t.line("exception classes: %s", BootRun.chain(broken.failure()));
t.line("root cause: %s", Ctx.root(broken.failure()).getMessage());
t.section("Handler injects ObjectProvider<Dispatcher>");
var fixed = Ctx.tryStart(LookupDispatcher.class, LazyLookupHandler.class);
t.line("started: %s", fixed.started());
assertThat(fixed.started()).isTrue();
t.line("dispatch(\"ping\") -> %s", fixed.context().getBean(LookupDispatcher.class).dispatch("ping"));
fixed.closeQuietly();
}
}
@Configuration
static class DependsOnCycle {
@Bean
@DependsOn("b")
Object a() {
return new Object();
}
@Bean
@DependsOn("a")
Object b() {
return new Object();
}
}
@Test
void aDependsOnCycle() {
try (var t = new Transcript("34-depends-on-cycle.txt", "@DependsOn(\"b\") on a and @DependsOn(\"a\") on b: no injection involved at all")) {
var outcome = Ctx.tryStart(DependsOnCycle.class);
t.line("started: %s", outcome.started());
assertThat(outcome.started()).isFalse();
t.line("exception classes: %s", BootRun.chain(outcome.failure()));
t.line("root cause: %s", Ctx.root(outcome.failure()).getMessage());
}
}
}
@@ -41,12 +41,14 @@ public final class Transcript implements AutoCloseable {
@Override @Override
public void close() { public void close() {
out.flush(); out.flush();
// Absolute paths of whoever ran the build are environment noise, not a finding.
String text = buffer.toString().replace(System.getProperty("user.dir"), "<core-beans>");
try { try {
Files.createDirectories(path.getParent()); Files.createDirectories(path.getParent());
Files.writeString(path, buffer.toString()); Files.writeString(path, text);
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("could not write " + path, e); throw new IllegalStateException("could not write " + path, e);
} }
System.out.print(buffer); System.out.print(text);
} }
} }