diff --git a/README.md b/README.md
index 82486f5..c0f8501 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,7 @@ files.
| [`etag-caching/`](etag-caching) | [Mastering Cache Control with ETag in Spring Boot RESTful APIs](https://ankurm.com/etag-cache-control-rest-api-spring-boot/) | `spring-boot-starter-web`'s own POM now reading "deprecated in favor of spring-boot-starter-webmvc", `ShallowEtagHeaderFilter` and `WebRequest.checkNotModified()` re-verified unchanged on Spring Framework 7, deep cache vs shallow cache, and conditional `PUT` with `If-Match` as optimistic locking |
| [`restclient-basic-auth/`](restclient-basic-auth) | [Spring Boot RestTemplate with Basic Auth: A Modern Guide](https://ankurm.com/spring-boot-resttemplate-with-basic-auth-a-modern-guide/) | RestClient with Basic Auth two ways against a real embedded server, `{noop}` passwords confirmed to emit no runtime warning at all, `spring-boot-starter-restclient` as its own required Boot 4 module, and the `RestClient.exchange()` trap covered in depth by the [RestTemplate to RestClient migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/) |
| [`core-di/`](core-di) | [Dependency Injection in Spring Boot 4: Constructor vs Setter vs Field (and Why Field Injection Hurts)](https://ankurm.com/spring-boot-4-dependency-injection-constructor-setter-field/) and [@Autowired Explained: By-Type Resolution, @Qualifier, @Primary, ObjectProvider and List Injection](https://ankurm.com/spring-autowired-qualifier-primary-objectprovider-list-injection/) | one `OrderService` in three injection styles built with plain `new`, a field-injected dependency used in a constructor, circular dependencies in plain Spring vs Boot with the real failure report, `@Lazy` injecting a proxy, and the `@Autowired` resolution ladder read from Spring 7.0.9 bytecode (name match beats `@Priority`), plus the empty-collection trap that only bites field and setter injection |
+| [`core-beans/`](core-beans) | [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/) and [Spring Bean Lifecycle in Boot 4: @PostConstruct, InitializingBean, SmartLifecycle and Shutdown Order](https://ankurm.com/spring-bean-lifecycle-postconstruct-smartlifecycle-shutdown-order/) | instance counts per scope, five fixes for a prototype inside a singleton, request scope without a proxy failing at start-up, every lifecycle callback in order, `@PostConstruct` running before the proxy exists, `SmartLifecycle` phases measured, and a `SmartLifecycle` at the default phase stopping before Tomcat has drained |
`core-di` and `core-beans` are the exception: they have **no `docs/` folder**. Their deeper material lives in
collapsible sections inside the articles themselves, and their captured output sits in a top-level `output/`
diff --git a/core-beans/.gitignore b/core-beans/.gitignore
new file mode 100644
index 0000000..e97c6ee
--- /dev/null
+++ b/core-beans/.gitignore
@@ -0,0 +1,2 @@
+target/
+*.class
diff --git a/core-beans/README.md b/core-beans/README.md
new file mode 100644
index 0000000..480f2b2
--- /dev/null
+++ b/core-beans/README.md
@@ -0,0 +1,80 @@
+# core-beans
+
+Companion project for two articles on **[ankurm.com](https://ankurm.com)**.
+
+| 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 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 |
+
+Every console block, exception message and count quoted in those articles came out of `output/`,
+and every file there is regenerated by one script. Most are written by the test suite, so if a
+claim stops being true the build goes red.
+
+There is deliberately **no `docs/` folder**: the deeper material lives in collapsible "going
+deeper" sections inside the articles themselves, next to the paragraph each one extends.
+
+## Versions
+
+| | |
+|---|---|
+| Spring Boot | 4.1.1 |
+| Spring Framework | 7.0.9 |
+| JDK | 25 (Temurin 25.0.4.1+1) |
+| Maven | 3.9 |
+
+## Quickstart
+
+```bash
+export JAVA_HOME=/path/to/jdk-25
+mvn test # runs the scenarios and rewrites the test-written files in output/
+./scripts/run-all.sh # everything, including the script-captured files
+```
+
+## Source layout
+
+| Package | What it holds |
+|---|---|
+| `scopes/` | singleton, `@Lazy`, prototype, scoped-prototype and the five ways a singleton can obtain a prototype; the shared-state greeter |
+| `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` |
+| `shutdown/` | a web application with a `/slow` endpoint and two `SmartLifecycle` beans at different phases |
+
+## Endpoints
+
+Both endpoints exist only to be called by the tests. They have no authorisation. Delete them before shipping.
+
+| Endpoint | Application | Purpose |
+|---|---|---|
+| `GET /scopes` | `WebScopesApp` | request, session and application instance serials, and the injected proxy class |
+| `GET /slow?ms=1500` | `ShutdownApp` | sleeps, so a request can be in flight when the context closes |
+
+## 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.
+
+| File | What it shows |
+|---|---|
+| [`01-instance-counts.txt`](output/01-instance-counts.txt) | How many instances did the container really construct? |
+| [`02-prototype-destroy.txt`](output/02-prototype-destroy.txt) | @PreDestroy on a singleton and on a prototype, then context.close() |
+| [`03-prototype-in-singleton.txt`](output/03-prototype-in-singleton.txt) | A singleton calls use() five times. How many prototype instances did it touch? |
+| [`04-singleton-thread-safety.txt`](output/04-singleton-thread-safety.txt) | Two threads call the same singleton; a latch forces the interleaving |
+| [`05-web-scopes.txt`](output/05-web-scopes.txt) | request, session and application scope over real HTTP |
+| [`06-request-scope-without-proxy.txt`](output/06-request-scope-without-proxy.txt) | A request-scoped bean (no proxy) injected into a singleton |
+| [`07-full-callback-order.txt`](output/07-full-callback-order.txt) | Every callback for one bean, from constructor to the last destroy hook (SpringApplication, no web server) |
+| [`08-non-static-bpp-warning.txt`](output/08-non-static-bpp-warning.txt) | A BeanPostProcessor declared with a non-static @Bean method |
+| [`09-postconstruct-before-proxy.txt`](output/09-postconstruct-before-proxy.txt) | @PostConstruct runs before the @Async proxy exists |
+| [`10-postconstruct-failure.txt`](output/10-postconstruct-failure.txt) | An exception thrown from @PostConstruct |
+| [`11-smartlifecycle-phases.txt`](output/11-smartlifecycle-phases.txt) | Three SmartLifecycle beans registered in the order 300, 100, 200 |
+| [`12-blocking-vs-async-stop.txt`](output/12-blocking-vs-async-stop.txt) | Three SmartLifecycle beans in the SAME phase, each needing 400 ms to stop |
+| [`13-plain-lifecycle.txt`](output/13-plain-lifecycle.txt) | Lifecycle vs SmartLifecycle: who starts at refresh()? |
+| [`14-shutdown-timeout.txt`](output/14-shutdown-timeout.txt) | A SmartLifecycle whose stop(callback) never calls the callback, timeout 500 ms |
+| [`15-smartlifecycle-beans-in-boot.txt`](output/15-smartlifecycle-beans-in-boot.txt) | Every SmartLifecycle bean in a Boot web application, highest phase (stops first) at the top |
+| [`16-graceful-shutdown-in-flight.txt`](output/16-graceful-shutdown-in-flight.txt) | A /slow?ms=1500 request is in flight when the context closes |
+| [`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) |
+| [`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 |
+
+## Licence
+
+MIT, see the repository root.
diff --git a/core-beans/output/01-instance-counts.txt b/core-beans/output/01-instance-counts.txt
new file mode 100644
index 0000000..bb4e26a
--- /dev/null
+++ b/core-beans/output/01-instance-counts.txt
@@ -0,0 +1,14 @@
+# How many instances did the container really construct?
+
+right after refresh():
+ singleton instances : 1
+ @Lazy singleton : 0
+ prototype : 0
+
+after getBean() twice for singleton and lazy, three times for prototype:
+ singleton instances : 1 same object both times: true
+ @Lazy singleton : 1 same object both times: true
+ prototype : 3 serials: 1, 2, 3
+
+two separate contexts, each with SingletonBean:
+ singleton instances : 2 same object across contexts: false
diff --git a/core-beans/output/02-prototype-destroy.txt b/core-beans/output/02-prototype-destroy.txt
new file mode 100644
index 0000000..eb612ac
--- /dev/null
+++ b/core-beans/output/02-prototype-destroy.txt
@@ -0,0 +1,4 @@
+# @PreDestroy on a singleton and on a prototype, then context.close()
+
+prototype instances created: 1
+callbacks after close(): [singleton @PreDestroy called]
diff --git a/core-beans/output/03-prototype-in-singleton.txt b/core-beans/output/03-prototype-in-singleton.txt
new file mode 100644
index 0000000..ffdcd6c
--- /dev/null
+++ b/core-beans/output/03-prototype-in-singleton.txt
@@ -0,0 +1,11 @@
+# A singleton calls use() five times. How many prototype instances did it touch?
+
+how the prototype is obtained serials seen instances built
+---------------------------------- ---------------------- ---------------
+constructor injection (naive) [1, 1, 1, 1, 1] 1
+ObjectProvider.getObject() [1, 2, 3, 4, 5] 5
+@Lookup method [1, 2, 3, 4, 5] 5
+scoped proxy (TARGET_CLASS) [1, 2, 3, 4, 5] 5
+ApplicationContext.getBean() [1, 2, 3, 4, 5] 5
+
+ProxyConsumer's injected reference is a com.ankurm.corebeans.scopes.ScopedPrototypeBean$$SpringCGLIB$$0
diff --git a/core-beans/output/04-singleton-thread-safety.txt b/core-beans/output/04-singleton-thread-safety.txt
new file mode 100644
index 0000000..a3b55c1
--- /dev/null
+++ b/core-beans/output/04-singleton-thread-safety.txt
@@ -0,0 +1,4 @@
+# Two threads call the same singleton; a latch forces the interleaving
+
+UnsafeGreeter (state in a field) : alice's call returned "Hello, bob"
+SafeGreeter (state in a local) : alice's call returned "Hello, alice"
diff --git a/core-beans/output/05-web-scopes.txt b/core-beans/output/05-web-scopes.txt
new file mode 100644
index 0000000..cc77035
--- /dev/null
+++ b/core-beans/output/05-web-scopes.txt
@@ -0,0 +1,10 @@
+# request, session and application scope over real HTTP
+
+client response
+A #1 request=1 session=1 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
+A #2 request=2 session=1 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
+A #3 request=3 session=1 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
+B #1 request=4 session=2 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
+B #2 request=5 session=2 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
+
+instances constructed: request=5 session=2 application=1
diff --git a/core-beans/output/06-request-scope-without-proxy.txt b/core-beans/output/06-request-scope-without-proxy.txt
new file mode 100644
index 0000000..fe8a330
--- /dev/null
+++ b/core-beans/output/06-request-scope-without-proxy.txt
@@ -0,0 +1,6 @@
+# A request-scoped bean (no proxy) injected into a singleton
+
+top exception : org.springframework.beans.factory.UnsatisfiedDependencyException
+top message : Error creating bean with name 'brokenRequestConsumer': Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'brokenRequestConsumer.RawRequestBean': Scope 'request' is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton
+
+root cause : java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.
diff --git a/core-beans/output/07-full-callback-order.txt b/core-beans/output/07-full-callback-order.txt
new file mode 100644
index 0000000..37fe940
--- /dev/null
+++ b/core-beans/output/07-full-callback-order.txt
@@ -0,0 +1,26 @@
+# Every callback for one bean, from constructor to the last destroy hook (SpringApplication, no web server)
+
+=== startup ===
+constructor
+setter injection (@Autowired setDependency)
+BeanNameAware.setBeanName("kitchenSink")
+BeanFactoryAware.setBeanFactory
+ApplicationContextAware.setApplicationContext
+BeanPostProcessor.postProcessBeforeInitialization
+@PostConstruct
+InitializingBean.afterPropertiesSet
+@Bean(initMethod = "customInit")
+BeanPostProcessor.postProcessAfterInitialization
+SmartInitializingSingleton.afterSingletonsInstantiated
+SmartLifecycle.start (phase 2147483647)
+ContextRefreshedEvent
+ApplicationStartedEvent
+ApplicationRunner.run
+ApplicationReadyEvent
+
+=== ctx.close() ===
+ContextClosedEvent
+SmartLifecycle.stop
+@PreDestroy
+DisposableBean.destroy
+@Bean(destroyMethod = "customDestroy")
diff --git a/core-beans/output/08-non-static-bpp-warning.txt b/core-beans/output/08-non-static-bpp-warning.txt
new file mode 100644
index 0000000..76f7fd1
--- /dev/null
+++ b/core-beans/output/08-non-static-bpp-warning.txt
@@ -0,0 +1,3 @@
+# A BeanPostProcessor declared with a non-static @Bean method
+
+2026-09-24T10:28:59.979+05:30 WARN 3358 --- [core-beans] [ main] trationDelegate$BeanPostProcessorChecker : Bean 'nonStaticProcessorConfig' of type [com.ankurm.corebeans.lifecycle.NonStaticProcessorConfig$$SpringCGLIB$$0] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). The currently created BeanPostProcessor [quietProcessor] is declared through a non-static factory method on that class; consider declaring it as static instead.
diff --git a/core-beans/output/09-postconstruct-before-proxy.txt b/core-beans/output/09-postconstruct-before-proxy.txt
new file mode 100644
index 0000000..405650c
--- /dev/null
+++ b/core-beans/output/09-postconstruct-before-proxy.txt
@@ -0,0 +1,4 @@
+# @PostConstruct runs before the @Async proxy exists
+
+@PostConstruct sees this = com.ankurm.corebeans.lifecycle.AsyncMailer, isAopProxy(this) = false
+the bean other code receives : com.ankurm.corebeans.lifecycle.AsyncMailer$$SpringCGLIB$$0
diff --git a/core-beans/output/10-postconstruct-failure.txt b/core-beans/output/10-postconstruct-failure.txt
new file mode 100644
index 0000000..a5c1edb
--- /dev/null
+++ b/core-beans/output/10-postconstruct-failure.txt
@@ -0,0 +1,6 @@
+# An exception thrown from @PostConstruct
+
+context started: false
+top exception : org.springframework.beans.factory.BeanCreationException
+top message : Error creating bean with name 'failingInit': Invocation of init method failed
+root cause : java.lang.IllegalStateException: cache warm-up failed
diff --git a/core-beans/output/11-smartlifecycle-phases.txt b/core-beans/output/11-smartlifecycle-phases.txt
new file mode 100644
index 0000000..1976852
--- /dev/null
+++ b/core-beans/output/11-smartlifecycle-phases.txt
@@ -0,0 +1,10 @@
+# Three SmartLifecycle beans registered in the order 300, 100, 200
+
+=== refresh() ===
+start A (phase 100)
+start B (phase 200)
+start C (phase 300)
+=== close() ===
+stop C (phase 300)
+stop B (phase 200)
+stop A (phase 100)
diff --git a/core-beans/output/12-blocking-vs-async-stop.txt b/core-beans/output/12-blocking-vs-async-stop.txt
new file mode 100644
index 0000000..28d112c
--- /dev/null
+++ b/core-beans/output/12-blocking-vs-async-stop.txt
@@ -0,0 +1,4 @@
+# Three SmartLifecycle beans in the SAME phase, each needing 400 ms to stop
+
+stop(Runnable) blocks (the default) : close() took >= 1100 ms (sequential)
+stop(Runnable) returns immediately : close() took < 800 ms (concurrent)
diff --git a/core-beans/output/13-plain-lifecycle.txt b/core-beans/output/13-plain-lifecycle.txt
new file mode 100644
index 0000000..d784b60
--- /dev/null
+++ b/core-beans/output/13-plain-lifecycle.txt
@@ -0,0 +1,4 @@
+# Lifecycle vs SmartLifecycle: who starts at refresh()?
+
+after refresh() : events=[] isRunning=false
+after start() : events=[PlainLifecycle.start] isRunning=true
diff --git a/core-beans/output/14-shutdown-timeout.txt b/core-beans/output/14-shutdown-timeout.txt
new file mode 100644
index 0000000..9359b93
--- /dev/null
+++ b/core-beans/output/14-shutdown-timeout.txt
@@ -0,0 +1,4 @@
+# A SmartLifecycle whose stop(callback) never calls the callback, timeout 500 ms
+
+close() returned after roughly the timeout: true
+2026-09-24T10:28:58.263+05:30 INFO 3358 --- [core-beans] [ main] o.s.c.support.DefaultLifecycleProcessor : Shutdown phase 2147483647 ends with 1 bean still running after timeout of 500ms: [neverStops]
diff --git a/core-beans/output/15-smartlifecycle-beans-in-boot.txt b/core-beans/output/15-smartlifecycle-beans-in-boot.txt
new file mode 100644
index 0000000..a181cc2
--- /dev/null
+++ b/core-beans/output/15-smartlifecycle-beans-in-boot.txt
@@ -0,0 +1,11 @@
+# Every SmartLifecycle bean in a Boot web application, highest phase (stops first) at the top
+
+phase bean name class
+2147483647 earlyWorker EarlyWorker
+2147482623 webServerGracefulShutdown WebServerGracefulShutdownLifecycle
+2147481599 webServerStartStop WebServerStartStopLifecycle
+1073741823 applicationTaskExecutor ThreadPoolTaskExecutor
+1000 lateWorker LateWorker
+-2147483647 springBootLoggingLifecycle Lifecycle
+
+SmartLifecycle.DEFAULT_PHASE = 2147483647
diff --git a/core-beans/output/16-graceful-shutdown-in-flight.txt b/core-beans/output/16-graceful-shutdown-in-flight.txt
new file mode 100644
index 0000000..db884fd
--- /dev/null
+++ b/core-beans/output/16-graceful-shutdown-in-flight.txt
@@ -0,0 +1,7 @@
+# A /slow?ms=1500 request is in flight when the context closes
+
+settings the client saw close() blocked handler ran to the end
+-------------------------------------------- ---------------------------------------------------- -------------------------------- ----------------------
+server.shutdown=graceful, virtual threads HTTP 200: finished after 1500 ms, virtual thread = true >= 1 s (waited for the request) yes
+server.shutdown=graceful, platform threads HTTP 200: finished after 1500 ms, virtual thread = false >= 1 s (waited for the request) yes
+server.shutdown=immediate, virtual threads FAILED: connection dropped, no response >= 1 s (waited for the request) yes
diff --git a/core-beans/output/17-worker-phase-vs-web-server.txt b/core-beans/output/17-worker-phase-vs-web-server.txt
new file mode 100644
index 0000000..5678467
--- /dev/null
+++ b/core-beans/output/17-worker-phase-vs-web-server.txt
@@ -0,0 +1,5 @@
+# SmartLifecycle beans with the default phase and with phase 1000, while a request is in flight
+
+EarlyWorker.stop (phase 2147483647): requests still in flight = 1
+LateWorker.stop (phase 1000): requests still in flight = 0
+the client saw: HTTP 200: finished after 1500 ms, virtual thread = true
diff --git a/core-beans/output/18-property-defaults.txt b/core-beans/output/18-property-defaults.txt
new file mode 100644
index 0000000..6ee5fd4
--- /dev/null
+++ b/core-beans/output/18-property-defaults.txt
@@ -0,0 +1,5 @@
+# Property defaults read from spring-configuration-metadata.json (Boot 4.1.1 jars)
+
+spring.lifecycle.timeout-per-shutdown-phase type=java.time.Duration default=30s
+spring.threads.virtual.enabled type=java.lang.Boolean default=False
+server.shutdown type=org.springframework.boot.web.server.Shutdown default=graceful
diff --git a/core-beans/output/19-bean-post-processors.txt b/core-beans/output/19-bean-post-processors.txt
new file mode 100644
index 0000000..7659096
--- /dev/null
+++ b/core-beans/output/19-bean-post-processors.txt
@@ -0,0 +1,9 @@
+# The BeanPostProcessors registered in a plain Spring Boot context, in the order they run
+
+ 1 org.springframework.context.support.ApplicationContextAwareProcessor
+ 2 org.springframework.context.annotation.ConfigurationClassPostProcessor$ImportAwareBeanPostProcessor
+ 3 org.springframework.context.support.PostProcessorRegistrationDelegate$BeanPostProcessorChecker
+ 4 com.ankurm.corebeans.lifecycle.TracingPostProcessor
+ 5 org.springframework.context.annotation.CommonAnnotationBeanPostProcessor
+ 6 org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor
+ 7 org.springframework.context.support.ApplicationListenerDetector
diff --git a/core-beans/pom.xml b/core-beans/pom.xml
new file mode 100644
index 0000000..eed87b7
--- /dev/null
+++ b/core-beans/pom.xml
@@ -0,0 +1,48 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ core-beans
+ 1.0.0
+ core-beans
+ Spring bean scopes and the bean lifecycle in Spring Boot 4
+
+
+ 25
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/core-beans/scripts/capture-metadata.sh b/core-beans/scripts/capture-metadata.sh
new file mode 100755
index 0000000..7e725d2
--- /dev/null
+++ b/core-beans/scripts/capture-metadata.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Reads the shutdown-related property defaults out of Boot's own configuration metadata, and the
+# SmartLifecycle phase constants out of the Boot jars with javap, instead of trusting doc prose.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+mkdir -p output target/meta
+BOOT=4.1.1
+M2=~/.m2/repository/org/springframework/boot
+{
+ echo "# Property defaults read from spring-configuration-metadata.json (Boot $BOOT jars)"
+ echo
+ for jar in $(find "$M2" -name "*-$BOOT.jar" ! -name '*sources*' ! -name '*javadoc*' | sort); do
+ { unzip -p "$jar" META-INF/spring-configuration-metadata.json 2>/dev/null || true; } | python3 -c '
+import json,sys
+try:
+ d=json.load(sys.stdin)
+except Exception:
+ sys.exit(0)
+for p in d.get("properties",[]):
+ if p["name"] in ("server.shutdown","spring.lifecycle.timeout-per-shutdown-phase","spring.threads.virtual.enabled"):
+ print("%-46s type=%-40s default=%s" % (p["name"], p.get("type"), p.get("defaultValue")))
+'
+ done
+} > output/18-property-defaults.txt
+cat output/18-property-defaults.txt
diff --git a/core-beans/scripts/run-all.sh b/core-beans/scripts/run-all.sh
new file mode 100755
index 0000000..42cb807
--- /dev/null
+++ b/core-beans/scripts/run-all.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# Regenerates every file under output/.
+#
+# ./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:
+# the figures in the two articles are assertions that fail the build if they stop being true.
+# Timing-based rows (12, 16) assert coarse thresholds, not exact milliseconds.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+echo "== test suite (transcripts 01-17)"
+mvn -B test
+
+echo "== property defaults read from Boot's metadata (18)"
+./scripts/capture-metadata.sh
+
+echo
+echo "output:"
+ls -1 output
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/Trace.java b/core-beans/src/main/java/com/ankurm/corebeans/Trace.java
new file mode 100644
index 0000000..34833a1
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/Trace.java
@@ -0,0 +1,27 @@
+package com.ankurm.corebeans;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** A shared event log so a test can print the order in which Spring touches a bean. */
+public final class Trace {
+
+ private static final List EVENTS = Collections.synchronizedList(new ArrayList<>());
+
+ private Trace() {
+ }
+
+ public static void log(String event) {
+ EVENTS.add(event);
+ }
+
+ public static List drain() {
+ List copy;
+ synchronized (EVENTS) {
+ copy = new ArrayList<>(EVENTS);
+ EVENTS.clear();
+ }
+ return copy;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/AsyncMailer.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/AsyncMailer.java
new file mode 100644
index 0000000..f366f76
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/AsyncMailer.java
@@ -0,0 +1,26 @@
+package com.ankurm.corebeans.lifecycle;
+
+import com.ankurm.corebeans.Trace;
+import jakarta.annotation.PostConstruct;
+import org.springframework.aop.support.AopUtils;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+/** A bean that gets proxied ({@code @Async}); @PostConstruct runs on the raw target, before the proxy exists. */
+public class AsyncMailer {
+
+ @PostConstruct
+ void init() {
+ Trace.log("@PostConstruct sees this = " + getClass().getName() + ", isAopProxy(this) = "
+ + AopUtils.isAopProxy(this));
+ }
+
+ @Async
+ public void send() {
+ }
+
+ @org.springframework.context.annotation.Configuration
+ @EnableAsync
+ public static class Config {
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/Dependency.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/Dependency.java
new file mode 100644
index 0000000..51c3d1d
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/Dependency.java
@@ -0,0 +1,4 @@
+package com.ankurm.corebeans.lifecycle;
+
+public class Dependency {
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/EventLogger.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/EventLogger.java
new file mode 100644
index 0000000..aa8ac71
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/EventLogger.java
@@ -0,0 +1,39 @@
+package com.ankurm.corebeans.lifecycle;
+
+import com.ankurm.corebeans.Trace;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.boot.context.event.ApplicationStartedEvent;
+import org.springframework.context.event.ContextClosedEvent;
+import org.springframework.context.event.ContextRefreshedEvent;
+import org.springframework.context.event.EventListener;
+
+/** Where the application events fall between the bean callbacks. Only fires under SpringApplication. */
+public class EventLogger implements ApplicationRunner {
+
+ @EventListener
+ void refreshed(ContextRefreshedEvent e) {
+ Trace.log("ContextRefreshedEvent");
+ }
+
+ @EventListener
+ void started(ApplicationStartedEvent e) {
+ Trace.log("ApplicationStartedEvent");
+ }
+
+ @Override
+ public void run(ApplicationArguments args) {
+ Trace.log("ApplicationRunner.run");
+ }
+
+ @EventListener
+ void ready(ApplicationReadyEvent e) {
+ Trace.log("ApplicationReadyEvent");
+ }
+
+ @EventListener
+ void closing(ContextClosedEvent e) {
+ Trace.log("ContextClosedEvent");
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/FailingInit.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/FailingInit.java
new file mode 100644
index 0000000..47d20fa
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/FailingInit.java
@@ -0,0 +1,11 @@
+package com.ankurm.corebeans.lifecycle;
+
+import jakarta.annotation.PostConstruct;
+
+public class FailingInit {
+
+ @PostConstruct
+ void warmUp() {
+ throw new IllegalStateException("cache warm-up failed");
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/KitchenSink.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/KitchenSink.java
new file mode 100644
index 0000000..392277d
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/KitchenSink.java
@@ -0,0 +1,102 @@
+package com.ankurm.corebeans.lifecycle;
+
+import com.ankurm.corebeans.Trace;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.BeanFactory;
+import org.springframework.beans.factory.BeanFactoryAware;
+import org.springframework.beans.factory.BeanNameAware;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.SmartInitializingSingleton;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationContextAware;
+import org.springframework.context.SmartLifecycle;
+
+/**
+ * One bean that implements every lifecycle hook Spring offers, each one logging when it is called.
+ * Nobody should write a class like this; it exists so the order can be printed from a real run.
+ */
+public class KitchenSink implements BeanNameAware, BeanFactoryAware, ApplicationContextAware,
+ InitializingBean, DisposableBean, SmartInitializingSingleton, SmartLifecycle {
+
+ private volatile boolean running;
+
+ public KitchenSink() {
+ Trace.log("constructor");
+ }
+
+ @Autowired
+ public void setDependency(Dependency dependency) {
+ Trace.log("setter injection (@Autowired setDependency)");
+ }
+
+ @Override
+ public void setBeanName(String name) {
+ Trace.log("BeanNameAware.setBeanName(\"" + name + "\")");
+ }
+
+ @Override
+ public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
+ Trace.log("BeanFactoryAware.setBeanFactory");
+ }
+
+ @Override
+ public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
+ Trace.log("ApplicationContextAware.setApplicationContext");
+ }
+
+ @PostConstruct
+ void postConstruct() {
+ Trace.log("@PostConstruct");
+ }
+
+ @Override
+ public void afterPropertiesSet() {
+ Trace.log("InitializingBean.afterPropertiesSet");
+ }
+
+ /** Named by {@code @Bean(initMethod = "customInit")}. */
+ public void customInit() {
+ Trace.log("@Bean(initMethod = \"customInit\")");
+ }
+
+ @Override
+ public void afterSingletonsInstantiated() {
+ Trace.log("SmartInitializingSingleton.afterSingletonsInstantiated");
+ }
+
+ @Override
+ public void start() {
+ running = true;
+ Trace.log("SmartLifecycle.start (phase " + getPhase() + ")");
+ }
+
+ @Override
+ public void stop() {
+ running = false;
+ Trace.log("SmartLifecycle.stop");
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running;
+ }
+
+ @PreDestroy
+ void preDestroy() {
+ Trace.log("@PreDestroy");
+ }
+
+ @Override
+ public void destroy() {
+ Trace.log("DisposableBean.destroy");
+ }
+
+ /** Named by {@code @Bean(destroyMethod = "customDestroy")}. */
+ public void customDestroy() {
+ Trace.log("@Bean(destroyMethod = \"customDestroy\")");
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/LifecycleConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/LifecycleConfig.java
new file mode 100644
index 0000000..fcbd7f3
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/LifecycleConfig.java
@@ -0,0 +1,23 @@
+package com.ankurm.corebeans.lifecycle;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class LifecycleConfig {
+
+ @Bean
+ static TracingPostProcessor tracingPostProcessor() {
+ return new TracingPostProcessor();
+ }
+
+ @Bean
+ Dependency dependency() {
+ return new Dependency();
+ }
+
+ @Bean(initMethod = "customInit", destroyMethod = "customDestroy")
+ KitchenSink kitchenSink() {
+ return new KitchenSink();
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NeverStops.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NeverStops.java
new file mode 100644
index 0000000..22a8d89
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NeverStops.java
@@ -0,0 +1,29 @@
+package com.ankurm.corebeans.lifecycle;
+
+import org.springframework.context.SmartLifecycle;
+
+/** A SmartLifecycle whose asynchronous stop never invokes the callback: shutdown must time out. */
+public class NeverStops implements SmartLifecycle {
+
+ private volatile boolean running;
+
+ @Override
+ public void start() {
+ running = true;
+ }
+
+ @Override
+ public void stop() {
+ running = false;
+ }
+
+ @Override
+ public void stop(Runnable callback) {
+ // forgot to call callback.run()
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NonStaticProcessorConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NonStaticProcessorConfig.java
new file mode 100644
index 0000000..3e23249
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NonStaticProcessorConfig.java
@@ -0,0 +1,21 @@
+package com.ankurm.corebeans.lifecycle;
+
+import org.springframework.beans.factory.config.BeanPostProcessor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/** A BeanPostProcessor declared with a NON-static {@code @Bean} method: the trap. */
+@Configuration
+public class NonStaticProcessorConfig {
+
+ @Bean
+ BeanPostProcessor quietProcessor() {
+ return new BeanPostProcessor() {
+ };
+ }
+
+ @Bean
+ Dependency plainBean() {
+ return new Dependency();
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PhasedWorker.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PhasedWorker.java
new file mode 100644
index 0000000..b85b416
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PhasedWorker.java
@@ -0,0 +1,69 @@
+package com.ankurm.corebeans.lifecycle;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import com.ankurm.corebeans.Trace;
+import org.springframework.context.SmartLifecycle;
+
+/** A SmartLifecycle with a chosen phase and a stop that takes a chosen time, blocking or asynchronous. */
+public class PhasedWorker implements SmartLifecycle {
+
+ private final String name;
+ private final int phase;
+ private final long stopMillis;
+ private final boolean asyncStop;
+ private final AtomicBoolean running = new AtomicBoolean();
+
+ public PhasedWorker(String name, int phase, long stopMillis, boolean asyncStop) {
+ this.name = name;
+ this.phase = phase;
+ this.stopMillis = stopMillis;
+ this.asyncStop = asyncStop;
+ }
+
+ @Override
+ public int getPhase() {
+ return phase;
+ }
+
+ @Override
+ public void start() {
+ running.set(true);
+ Trace.log("start " + name + " (phase " + phase + ")");
+ }
+
+ /** Blocking variant: Spring's default stop(Runnable) calls this, then the callback, on its own thread. */
+ @Override
+ public void stop() {
+ sleep(stopMillis);
+ running.set(false);
+ Trace.log("stop " + name + " (phase " + phase + ")");
+ }
+
+ @Override
+ public void stop(Runnable callback) {
+ if (!asyncStop) {
+ SmartLifecycle.super.stop(callback);
+ return;
+ }
+ Thread.ofVirtual().start(() -> {
+ sleep(stopMillis);
+ running.set(false);
+ Trace.log("stop " + name + " (phase " + phase + ", async callback)");
+ callback.run();
+ });
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running.get();
+ }
+
+ private static void sleep(long millis) {
+ try {
+ Thread.sleep(millis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PlainLifecycle.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PlainLifecycle.java
new file mode 100644
index 0000000..cc475c1
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PlainLifecycle.java
@@ -0,0 +1,27 @@
+package com.ankurm.corebeans.lifecycle;
+
+import com.ankurm.corebeans.Trace;
+import org.springframework.context.Lifecycle;
+
+/** A plain Lifecycle (not Smart): started only by an explicit context.start(), never by refresh. */
+public class PlainLifecycle implements Lifecycle {
+
+ private boolean running;
+
+ @Override
+ public void start() {
+ running = true;
+ Trace.log("PlainLifecycle.start");
+ }
+
+ @Override
+ public void stop() {
+ running = false;
+ Trace.log("PlainLifecycle.stop");
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/TracingPostProcessor.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/TracingPostProcessor.java
new file mode 100644
index 0000000..da7a59b
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/TracingPostProcessor.java
@@ -0,0 +1,25 @@
+package com.ankurm.corebeans.lifecycle;
+
+import com.ankurm.corebeans.Trace;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.BeanPostProcessor;
+
+/** Logs the two BeanPostProcessor callbacks, but only for the bean named kitchenSink. */
+public class TracingPostProcessor implements BeanPostProcessor {
+
+ @Override
+ public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
+ if (beanName.equals("kitchenSink")) {
+ Trace.log("BeanPostProcessor.postProcessBeforeInitialization");
+ }
+ return bean;
+ }
+
+ @Override
+ public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
+ if (beanName.equals("kitchenSink")) {
+ Trace.log("BeanPostProcessor.postProcessAfterInitialization");
+ }
+ return bean;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/ContextConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ContextConsumer.java
new file mode 100644
index 0000000..3fe33d2
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ContextConsumer.java
@@ -0,0 +1,17 @@
+package com.ankurm.corebeans.scopes;
+
+import org.springframework.context.ApplicationContext;
+
+/** Fix 4 (the one to avoid): reach back into the container. It works and couples the class to Spring. */
+public class ContextConsumer {
+
+ private final ApplicationContext context;
+
+ public ContextConsumer(ApplicationContext context) {
+ this.context = context;
+ }
+
+ public int use() {
+ return context.getBean(PrototypeBean.class).serial();
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/Instances.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/Instances.java
new file mode 100644
index 0000000..451e8e4
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/Instances.java
@@ -0,0 +1,28 @@
+package com.ankurm.corebeans.scopes;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/** Counts how many instances of each demo bean the container has actually constructed. */
+public final class Instances {
+
+ private static final Map COUNTS = new ConcurrentHashMap<>();
+
+ private Instances() {
+ }
+
+ /** Called from a constructor; returns this instance's serial number (1, 2, 3, ...). */
+ public static int next(String key) {
+ return COUNTS.computeIfAbsent(key, k -> new AtomicInteger()).incrementAndGet();
+ }
+
+ public static int count(String key) {
+ AtomicInteger c = COUNTS.get(key);
+ return c == null ? 0 : c.get();
+ }
+
+ public static void reset() {
+ COUNTS.clear();
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/LazySingletonBean.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/LazySingletonBean.java
new file mode 100644
index 0000000..ae625d6
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/LazySingletonBean.java
@@ -0,0 +1,14 @@
+package com.ankurm.corebeans.scopes;
+
+import org.springframework.context.annotation.Lazy;
+
+/** Still a singleton, but not created until something asks for it. */
+@Lazy
+public class LazySingletonBean {
+
+ private final int serial = Instances.next("lazy");
+
+ public int serial() {
+ return serial;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/LookupConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/LookupConsumer.java
new file mode 100644
index 0000000..4a043ac
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/LookupConsumer.java
@@ -0,0 +1,14 @@
+package com.ankurm.corebeans.scopes;
+
+import org.springframework.beans.factory.annotation.Lookup;
+
+/** Fix 2: Spring subclasses this at runtime and overrides the abstract method to call getBean(). */
+public abstract class LookupConsumer {
+
+ @Lookup
+ protected abstract PrototypeBean create();
+
+ public int use() {
+ return create().serial();
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/NaiveConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/NaiveConsumer.java
new file mode 100644
index 0000000..dca13a7
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/NaiveConsumer.java
@@ -0,0 +1,15 @@
+package com.ankurm.corebeans.scopes;
+
+/** The trap: a singleton that receives a prototype through its constructor. */
+public class NaiveConsumer {
+
+ private final PrototypeBean prototype;
+
+ public NaiveConsumer(PrototypeBean prototype) {
+ this.prototype = prototype;
+ }
+
+ public int use() {
+ return prototype.serial();
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/PrototypeBean.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/PrototypeBean.java
new file mode 100644
index 0000000..816d6e1
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/PrototypeBean.java
@@ -0,0 +1,22 @@
+package com.ankurm.corebeans.scopes;
+
+import jakarta.annotation.PreDestroy;
+
+import com.ankurm.corebeans.Trace;
+import org.springframework.context.annotation.Scope;
+
+/** A new instance every time the container is asked for one. */
+@Scope("prototype")
+public class PrototypeBean {
+
+ private final int serial = Instances.next("prototype");
+
+ public int serial() {
+ return serial;
+ }
+
+ @PreDestroy
+ void destroy() {
+ Trace.log("prototype @PreDestroy called");
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProviderConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProviderConsumer.java
new file mode 100644
index 0000000..eb78168
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProviderConsumer.java
@@ -0,0 +1,17 @@
+package com.ankurm.corebeans.scopes;
+
+import org.springframework.beans.factory.ObjectProvider;
+
+/** Fix 1: inject a provider and ask it every time. */
+public class ProviderConsumer {
+
+ private final ObjectProvider provider;
+
+ public ProviderConsumer(ObjectProvider provider) {
+ this.provider = provider;
+ }
+
+ public int use() {
+ return provider.getObject().serial();
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProxyConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProxyConsumer.java
new file mode 100644
index 0000000..22aeca0
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProxyConsumer.java
@@ -0,0 +1,19 @@
+package com.ankurm.corebeans.scopes;
+
+/** Fix 3: inject a scoped proxy; the proxy fetches a fresh target for every call. */
+public class ProxyConsumer {
+
+ private final ScopedPrototypeBean prototype;
+
+ public ProxyConsumer(ScopedPrototypeBean prototype) {
+ this.prototype = prototype;
+ }
+
+ public int use() {
+ return prototype.serial();
+ }
+
+ public String injectedClass() {
+ return prototype.getClass().getName();
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/SafeGreeter.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/SafeGreeter.java
new file mode 100644
index 0000000..a4edbc9
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/SafeGreeter.java
@@ -0,0 +1,11 @@
+package com.ankurm.corebeans.scopes;
+
+/** The same singleton with the state in a parameter and a local variable: nothing shared to corrupt. */
+public class SafeGreeter {
+
+ public String greet(String user, Runnable pause) {
+ String currentUser = user;
+ pause.run();
+ return "Hello, " + currentUser;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/ScopedPrototypeBean.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ScopedPrototypeBean.java
new file mode 100644
index 0000000..4d8b1fb
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ScopedPrototypeBean.java
@@ -0,0 +1,15 @@
+package com.ankurm.corebeans.scopes;
+
+import org.springframework.context.annotation.Scope;
+import org.springframework.context.annotation.ScopedProxyMode;
+
+/** Prototype scope behind a proxy: every method call on the injected reference reaches a new target. */
+@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
+public class ScopedPrototypeBean {
+
+ private final int serial = Instances.next("scopedPrototype");
+
+ public int serial() {
+ return serial;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/SingletonBean.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/SingletonBean.java
new file mode 100644
index 0000000..cf887ef
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/SingletonBean.java
@@ -0,0 +1,20 @@
+package com.ankurm.corebeans.scopes;
+
+import jakarta.annotation.PreDestroy;
+
+import com.ankurm.corebeans.Trace;
+
+/** No {@code @Scope}: the default. One instance per container, created at start-up. */
+public class SingletonBean {
+
+ private final int serial = Instances.next("singleton");
+
+ public int serial() {
+ return serial;
+ }
+
+ @PreDestroy
+ void destroy() {
+ Trace.log("singleton @PreDestroy called");
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/UnsafeGreeter.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/UnsafeGreeter.java
new file mode 100644
index 0000000..138a8ca
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/UnsafeGreeter.java
@@ -0,0 +1,16 @@
+package com.ankurm.corebeans.scopes;
+
+/**
+ * A singleton that keeps per-caller data in a field. The {@code pause} hook lets a test force the
+ * interleaving that production traffic produces only occasionally, so the demonstration is exact.
+ */
+public class UnsafeGreeter {
+
+ private String currentUser;
+
+ public String greet(String user, Runnable pause) {
+ this.currentUser = user;
+ pause.run();
+ return "Hello, " + currentUser;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/shutdown/EarlyWorker.java b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/EarlyWorker.java
new file mode 100644
index 0000000..ecd28e5
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/EarlyWorker.java
@@ -0,0 +1,29 @@
+package com.ankurm.corebeans.shutdown;
+
+import com.ankurm.corebeans.Trace;
+import org.springframework.context.SmartLifecycle;
+import org.springframework.stereotype.Component;
+
+/** Leaves getPhase() at its default, which is Integer.MAX_VALUE. */
+@Component
+public class EarlyWorker implements SmartLifecycle {
+
+ private volatile boolean running;
+
+ @Override
+ public void start() {
+ running = true;
+ }
+
+ @Override
+ public void stop() {
+ running = false;
+ Trace.log("EarlyWorker.stop (phase " + getPhase() + "): requests still in flight = "
+ + SlowController.IN_FLIGHT.get());
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/shutdown/LateWorker.java b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/LateWorker.java
new file mode 100644
index 0000000..60ab4af
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/LateWorker.java
@@ -0,0 +1,34 @@
+package com.ankurm.corebeans.shutdown;
+
+import com.ankurm.corebeans.Trace;
+import org.springframework.context.SmartLifecycle;
+import org.springframework.stereotype.Component;
+
+/** Phase 1000: far below the web server's phases, so it stops after the server has drained. */
+@Component
+public class LateWorker implements SmartLifecycle {
+
+ private volatile boolean running;
+
+ @Override
+ public int getPhase() {
+ return 1000;
+ }
+
+ @Override
+ public void start() {
+ running = true;
+ }
+
+ @Override
+ public void stop() {
+ running = false;
+ Trace.log("LateWorker.stop (phase " + getPhase() + "): requests still in flight = "
+ + SlowController.IN_FLIGHT.get());
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/shutdown/ShutdownApp.java b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/ShutdownApp.java
new file mode 100644
index 0000000..da4862d
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/ShutdownApp.java
@@ -0,0 +1,8 @@
+package com.ankurm.corebeans.shutdown;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/** A web application with one slow endpoint and two SmartLifecycle beans, used by the shutdown tests. */
+@SpringBootApplication
+public class ShutdownApp {
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/shutdown/SlowController.java b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/SlowController.java
new file mode 100644
index 0000000..cef8955
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/SlowController.java
@@ -0,0 +1,30 @@
+package com.ankurm.corebeans.shutdown;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/** Diagnostic only: delete before shipping. */
+@RestController
+public class SlowController {
+
+ /** How many /slow requests are executing right now. */
+ public static final AtomicInteger IN_FLIGHT = new AtomicInteger();
+
+ /** How many /slow handlers ran to the end, whether or not the client was still listening. */
+ public static final AtomicInteger COMPLETED = new AtomicInteger();
+
+ @GetMapping("/slow")
+ public String slow(@RequestParam long ms) throws InterruptedException {
+ IN_FLIGHT.incrementAndGet();
+ try {
+ Thread.sleep(ms);
+ COMPLETED.incrementAndGet();
+ return "finished after " + ms + " ms, virtual thread = " + Thread.currentThread().isVirtual();
+ } finally {
+ IN_FLIGHT.decrementAndGet();
+ }
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/ApplicationBean.java b/core-beans/src/main/java/com/ankurm/corebeans/web/ApplicationBean.java
new file mode 100644
index 0000000..d3baf87
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/web/ApplicationBean.java
@@ -0,0 +1,17 @@
+package com.ankurm.corebeans.web;
+
+import com.ankurm.corebeans.scopes.Instances;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.annotation.ApplicationScope;
+
+/** One per ServletContext. In a single Boot application that is one per JVM, so it looks like a singleton. */
+@Component
+@ApplicationScope
+public class ApplicationBean {
+
+ private final int serial = Instances.next("application");
+
+ public int serial() {
+ return serial;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/BrokenRequestConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/web/BrokenRequestConsumer.java
new file mode 100644
index 0000000..a31d6cd
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/web/BrokenRequestConsumer.java
@@ -0,0 +1,15 @@
+package com.ankurm.corebeans.web;
+
+import org.springframework.context.annotation.Scope;
+
+/** A singleton that takes a request-scoped bean WITHOUT a scoped proxy: the classic start-up failure. */
+public class BrokenRequestConsumer {
+
+ /** Registered by hand in the test, with proxyMode left at its default (NO). */
+ @Scope("request")
+ public static class RawRequestBean {
+ }
+
+ public BrokenRequestConsumer(RawRequestBean bean) {
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/RequestBean.java b/core-beans/src/main/java/com/ankurm/corebeans/web/RequestBean.java
new file mode 100644
index 0000000..175ad5d
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/web/RequestBean.java
@@ -0,0 +1,17 @@
+package com.ankurm.corebeans.web;
+
+import com.ankurm.corebeans.scopes.Instances;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.annotation.RequestScope;
+
+/** {@code @RequestScope} is {@code @Scope("request")} plus a TARGET_CLASS scoped proxy. */
+@Component
+@RequestScope
+public class RequestBean {
+
+ private final int serial = Instances.next("request");
+
+ public int serial() {
+ return serial;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/ScopesController.java b/core-beans/src/main/java/com/ankurm/corebeans/web/ScopesController.java
new file mode 100644
index 0000000..d64f5bc
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/web/ScopesController.java
@@ -0,0 +1,29 @@
+package com.ankurm.corebeans.web;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * A singleton controller holding three differently scoped collaborators. The injected references
+ * are scoped proxies, so each request reaches the instance that belongs to it.
+ * Diagnostic only: delete before shipping.
+ */
+@RestController
+public class ScopesController {
+
+ private final RequestBean request;
+ private final SessionBean session;
+ private final ApplicationBean application;
+
+ public ScopesController(RequestBean request, SessionBean session, ApplicationBean application) {
+ this.request = request;
+ this.session = session;
+ this.application = application;
+ }
+
+ @GetMapping("/scopes")
+ public String scopes() {
+ return "request=" + request.serial() + " session=" + session.serial() + " application="
+ + application.serial() + " injectedRequestClass=" + request.getClass().getSimpleName();
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/SessionBean.java b/core-beans/src/main/java/com/ankurm/corebeans/web/SessionBean.java
new file mode 100644
index 0000000..9999ed6
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/web/SessionBean.java
@@ -0,0 +1,16 @@
+package com.ankurm.corebeans.web;
+
+import com.ankurm.corebeans.scopes.Instances;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.annotation.SessionScope;
+
+@Component
+@SessionScope
+public class SessionBean {
+
+ private final int serial = Instances.next("session");
+
+ public int serial() {
+ return serial;
+ }
+}
diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/WebScopesApp.java b/core-beans/src/main/java/com/ankurm/corebeans/web/WebScopesApp.java
new file mode 100644
index 0000000..14fd87f
--- /dev/null
+++ b/core-beans/src/main/java/com/ankurm/corebeans/web/WebScopesApp.java
@@ -0,0 +1,8 @@
+package com.ankurm.corebeans.web;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/** A minimal servlet application, started by the tests. Scans only this package. */
+@SpringBootApplication
+public class WebScopesApp {
+}
diff --git a/core-beans/src/main/resources/application.yml b/core-beans/src/main/resources/application.yml
new file mode 100644
index 0000000..b645f84
--- /dev/null
+++ b/core-beans/src/main/resources/application.yml
@@ -0,0 +1,5 @@
+spring:
+ application:
+ name: core-beans
+ main:
+ banner-mode: off
diff --git a/core-beans/src/test/java/com/ankurm/corebeans/Ctx.java b/core-beans/src/test/java/com/ankurm/corebeans/Ctx.java
new file mode 100644
index 0000000..a1d3849
--- /dev/null
+++ b/core-beans/src/test/java/com/ankurm/corebeans/Ctx.java
@@ -0,0 +1,61 @@
+package com.ankurm.corebeans;
+
+import java.util.concurrent.Callable;
+
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+
+/** Small helpers so each test reads as the scenario, not the plumbing. */
+public final class Ctx {
+
+ private Ctx() {
+ }
+
+ /** A plain Spring context (no Boot) with the given classes registered and refreshed. */
+ public static AnnotationConfigApplicationContext plain(Class>... classes) {
+ var ctx = new AnnotationConfigApplicationContext();
+ ctx.register(classes);
+ ctx.refresh();
+ return ctx;
+ }
+
+ /** Refreshes a context and returns either "started" or the exception, so failures can be printed. */
+ public static Outcome tryStart(Class>... classes) {
+ var ctx = new AnnotationConfigApplicationContext();
+ try {
+ ctx.register(classes);
+ ctx.refresh();
+ return new Outcome(ctx, null);
+ } catch (RuntimeException e) {
+ ctx.close();
+ return new Outcome(null, e);
+ }
+ }
+
+ public static String attempt(Callable call) {
+ try {
+ return "OK -> " + call.call();
+ } catch (Exception e) {
+ return e.getClass().getSimpleName() + ": " + e.getMessage();
+ }
+ }
+
+ public static Throwable root(Throwable t) {
+ while (t.getCause() != null && t.getCause() != t) {
+ t = t.getCause();
+ }
+ return t;
+ }
+
+ public record Outcome(AnnotationConfigApplicationContext context, RuntimeException failure) {
+
+ public boolean started() {
+ return failure == null;
+ }
+
+ public void closeQuietly() {
+ if (context != null) {
+ context.close();
+ }
+ }
+ }
+}
diff --git a/core-beans/src/test/java/com/ankurm/corebeans/GracefulShutdownTest.java b/core-beans/src/test/java/com/ankurm/corebeans/GracefulShutdownTest.java
new file mode 100644
index 0000000..08c9c3d
--- /dev/null
+++ b/core-beans/src/test/java/com/ankurm/corebeans/GracefulShutdownTest.java
@@ -0,0 +1,91 @@
+package com.ankurm.corebeans;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.concurrent.CompletableFuture;
+
+import com.ankurm.corebeans.shutdown.ShutdownApp;
+import com.ankurm.corebeans.shutdown.SlowController;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.WebApplicationType;
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.context.ConfigurableApplicationContext;
+
+/** Post 19: what happens to a request that is mid-flight when the context closes. */
+class GracefulShutdownTest {
+
+ @BeforeEach
+ void reset() {
+ Trace.drain();
+ SlowController.COMPLETED.set(0);
+ }
+
+ private record Result(String response, long closeMillis, int handlersCompleted) {
+ }
+
+ private static Result runAndClose(String shutdownMode, boolean virtualThreads) throws Exception {
+ ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ShutdownApp.class)
+ .web(WebApplicationType.SERVLET)
+ .properties("server.port=0", "logging.level.root=OFF", "spring.main.banner-mode=off",
+ "server.shutdown=" + shutdownMode,
+ "spring.threads.virtual.enabled=" + virtualThreads,
+ "spring.lifecycle.timeout-per-shutdown-phase=10s")
+ .run();
+ int port = Integer.parseInt(ctx.getEnvironment().getProperty("local.server.port"));
+ HttpClient client = HttpClient.newHttpClient();
+ CompletableFuture pending = client
+ .sendAsync(HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/slow?ms=1500")).build(),
+ HttpResponse.BodyHandlers.ofString())
+ .thenApply(r -> "HTTP " + r.statusCode() + ": " + r.body())
+ .exceptionally(e -> "FAILED: connection dropped, no response");
+ long deadline = System.currentTimeMillis() + 5000;
+ while (SlowController.IN_FLIGHT.get() == 0 && System.currentTimeMillis() < deadline) {
+ Thread.sleep(10);
+ }
+ assertThat(SlowController.IN_FLIGHT.get()).isEqualTo(1);
+ long begin = System.nanoTime();
+ ctx.close();
+ long closeMs = (System.nanoTime() - begin) / 1_000_000;
+ String response = pending.get();
+ int completed = SlowController.COMPLETED.get();
+ SlowController.COMPLETED.set(0);
+ return new Result(response, closeMs, completed);
+ }
+
+ @Test
+ void inFlightRequestDuringClose() throws Exception {
+ try (var t = new Transcript("16-graceful-shutdown-in-flight.txt",
+ "A /slow?ms=1500 request is in flight when the context closes")) {
+ Result gracefulVirtual = runAndClose("graceful", true);
+ Result gracefulPlatform = runAndClose("graceful", false);
+ Result immediateVirtual = runAndClose("immediate", true);
+ t.line("%-44s %-52s %-32s %s", "settings", "the client saw", "close() blocked", "handler ran to the end");
+ t.line("%-44s %-52s %-32s %s", "-".repeat(44), "-".repeat(52), "-".repeat(32), "-".repeat(22));
+ row(t, "server.shutdown=graceful, virtual threads", gracefulVirtual);
+ row(t, "server.shutdown=graceful, platform threads", gracefulPlatform);
+ row(t, "server.shutdown=immediate, virtual threads", immediateVirtual);
+ assertThat(gracefulVirtual.response()).startsWith("HTTP 200");
+ assertThat(gracefulPlatform.response()).startsWith("HTTP 200");
+ }
+ }
+
+ private static void row(Transcript t, String label, Result r) {
+ String blocked = r.closeMillis() >= 1000 ? ">= 1 s (waited for the request)" : "< 1 s (did not wait)";
+ t.line("%-44s %-52s %-32s %s", label, r.response(), blocked, r.handlersCompleted() == 1 ? "yes" : "no");
+ }
+
+ @Test
+ void workerPhasesAgainstTheWebServer() throws Exception {
+ try (var t = new Transcript("17-worker-phase-vs-web-server.txt",
+ "SmartLifecycle beans with the default phase and with phase 1000, while a request is in flight")) {
+ Result r = runAndClose("graceful", true);
+ Trace.drain().forEach(e -> t.line("%s", e));
+ t.line("the client saw: %s", r.response());
+ }
+ }
+}
diff --git a/core-beans/src/test/java/com/ankurm/corebeans/LifecycleTest.java b/core-beans/src/test/java/com/ankurm/corebeans/LifecycleTest.java
new file mode 100644
index 0000000..cdbf4f4
--- /dev/null
+++ b/core-beans/src/test/java/com/ankurm/corebeans/LifecycleTest.java
@@ -0,0 +1,213 @@
+package com.ankurm.corebeans;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import com.ankurm.corebeans.lifecycle.*;
+import com.ankurm.corebeans.scopes.Instances;
+import com.ankurm.corebeans.shutdown.ShutdownApp;
+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;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.context.SmartLifecycle;
+import org.springframework.context.support.DefaultLifecycleProcessor;
+
+/** Post 19: the bean lifecycle, printed from real runs. */
+@ExtendWith(OutputCaptureExtension.class)
+class LifecycleTest {
+
+ @BeforeEach
+ void reset() {
+ Instances.reset();
+ Trace.drain();
+ // Earlier tests start Boot contexts with logging.level.root=OFF, and that state outlives them.
+ ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME))
+ .setLevel(ch.qos.logback.classic.Level.INFO);
+ }
+
+ @Test
+ void fullOrderUnderSpringBoot() {
+ try (var t = new Transcript("07-full-callback-order.txt",
+ "Every callback for one bean, from constructor to the last destroy hook (SpringApplication, no web server)")) {
+ ConfigurableApplicationContext ctx = new SpringApplicationBuilder(LifecycleConfig.class, EventLogger.class)
+ .web(WebApplicationType.NONE)
+ .properties("logging.level.root=OFF", "spring.main.banner-mode=off")
+ .run();
+ t.line("=== startup ===");
+ Trace.drain().forEach(e -> t.line("%s", e));
+ t.blank();
+ t.line("=== ctx.close() ===");
+ ctx.close();
+ var shutdown = Trace.drain();
+ shutdown.forEach(e -> t.line("%s", e));
+ assertThat(shutdown).contains("@PreDestroy", "DisposableBean.destroy");
+ }
+ }
+
+ @Test
+ void beanPostProcessorsInABootContext() {
+ try (var t = new Transcript("19-bean-post-processors.txt",
+ "The BeanPostProcessors registered in a plain Spring Boot context, in the order they run")) {
+ ConfigurableApplicationContext ctx = new SpringApplicationBuilder(LifecycleConfig.class)
+ .web(WebApplicationType.NONE)
+ .properties("logging.level.root=OFF", "spring.main.banner-mode=off")
+ .run();
+ var bf = (org.springframework.beans.factory.support.AbstractBeanFactory) ctx.getBeanFactory();
+ int i = 1;
+ for (var bpp : bf.getBeanPostProcessors()) {
+ t.line("%2d %s", i++, bpp.getClass().getName());
+ }
+ ctx.close();
+ assertThat(bf.getBeanPostProcessors()).isNotEmpty();
+ }
+ }
+
+ @Test
+ void nonStaticBeanPostProcessorWarns(CapturedOutput output) {
+ try (var t = new Transcript("08-non-static-bpp-warning.txt",
+ "A BeanPostProcessor declared with a non-static @Bean method")) {
+ try (var ctx = Ctx.plain(NonStaticProcessorConfig.class)) {
+ // context started; only the log matters
+ }
+ List lines = output.getAll().lines()
+ .filter(l -> l.contains("non-static") || l.contains("not eligible for getting processed"))
+ .toList();
+ lines.forEach(l -> t.line("%s", l));
+ assertThat(lines).isNotEmpty();
+ }
+ }
+
+ @Test
+ void postConstructRunsOnTheRawTarget() {
+ try (var t = new Transcript("09-postconstruct-before-proxy.txt",
+ "@PostConstruct runs before the @Async proxy exists")) {
+ try (var ctx = Ctx.plain(AsyncMailer.Config.class, AsyncMailer.class)) {
+ Trace.drain().forEach(e -> t.line("%s", e));
+ t.line("the bean other code receives : %s", ctx.getBean(AsyncMailer.class).getClass().getName());
+ assertThat(ctx.getBean(AsyncMailer.class).getClass().getName()).contains("SpringCGLIB");
+ }
+ }
+ }
+
+ @Test
+ void failingPostConstructStopsStartup() {
+ try (var t = new Transcript("10-postconstruct-failure.txt", "An exception thrown from @PostConstruct")) {
+ var outcome = Ctx.tryStart(FailingInit.class);
+ assertThat(outcome.started()).isFalse();
+ t.line("context started: false");
+ 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 smartLifecyclePhases() {
+ try (var t = new Transcript("11-smartlifecycle-phases.txt",
+ "Three SmartLifecycle beans registered in the order 300, 100, 200")) {
+ var ctx = new org.springframework.context.annotation.AnnotationConfigApplicationContext();
+ ctx.registerBean("workerC", PhasedWorker.class, () -> new PhasedWorker("C", 300, 0, false));
+ ctx.registerBean("workerA", PhasedWorker.class, () -> new PhasedWorker("A", 100, 0, false));
+ ctx.registerBean("workerB", PhasedWorker.class, () -> new PhasedWorker("B", 200, 0, false));
+ ctx.refresh();
+ t.line("=== refresh() ===");
+ Trace.drain().forEach(e -> t.line("%s", e));
+ ctx.close();
+ t.line("=== close() ===");
+ var stops = Trace.drain();
+ stops.forEach(e -> t.line("%s", e));
+ assertThat(stops.get(0)).contains("phase 300");
+ assertThat(stops.get(2)).contains("phase 100");
+ }
+ }
+
+ @Test
+ void sameScopeBlockingVersusAsyncStop() {
+ try (var t = new Transcript("12-blocking-vs-async-stop.txt",
+ "Three SmartLifecycle beans in the SAME phase, each needing 400 ms to stop")) {
+ for (boolean async : new boolean[] {false, true}) {
+ var ctx = new org.springframework.context.annotation.AnnotationConfigApplicationContext();
+ for (String name : List.of("one", "two", "three")) {
+ ctx.registerBean(name, PhasedWorker.class, () -> new PhasedWorker(name, 0, 400, async));
+ }
+ ctx.refresh();
+ Trace.drain();
+ long begin = System.nanoTime();
+ ctx.close();
+ long ms = (System.nanoTime() - begin) / 1_000_000;
+ t.line("stop(Runnable) %-22s: close() took %s", async ? "returns immediately" : "blocks (the default)",
+ ms >= 1100 ? ">= 1100 ms (sequential)" : ms < 800 ? "< 800 ms (concurrent)" : "in between");
+ if (async) {
+ assertThat(ms).isLessThan(800);
+ } else {
+ assertThat(ms).isGreaterThanOrEqualTo(1100);
+ }
+ }
+ }
+ }
+
+ @Test
+ void plainLifecycleIsNotAutoStarted() {
+ try (var t = new Transcript("13-plain-lifecycle.txt", "Lifecycle vs SmartLifecycle: who starts at refresh()?")) {
+ var ctx = Ctx.plain(PlainLifecycle.class);
+ t.line("after refresh() : events=%s isRunning=%s", Trace.drain(), ctx.getBean(PlainLifecycle.class).isRunning());
+ assertThat(ctx.getBean(PlainLifecycle.class).isRunning()).isFalse();
+ ctx.start();
+ t.line("after start() : events=%s isRunning=%s", Trace.drain(), ctx.getBean(PlainLifecycle.class).isRunning());
+ ctx.close();
+ }
+ }
+
+ @Test
+ void shutdownPhaseTimeout(CapturedOutput output) {
+ try (var t = new Transcript("14-shutdown-timeout.txt",
+ "A SmartLifecycle whose stop(callback) never calls the callback, timeout 500 ms")) {
+ var ctx = new org.springframework.context.annotation.AnnotationConfigApplicationContext();
+ ctx.registerBean("lifecycleProcessor", DefaultLifecycleProcessor.class, () -> {
+ var p = new DefaultLifecycleProcessor();
+ p.setTimeoutPerShutdownPhase(500);
+ return p;
+ });
+ ctx.registerBean("neverStops", NeverStops.class);
+ ctx.refresh();
+ long begin = System.nanoTime();
+ ctx.close();
+ long ms = (System.nanoTime() - begin) / 1_000_000;
+ t.line("close() returned after roughly the timeout: %s", ms >= 450 && ms < 3000);
+ output.getAll().lines().filter(l -> l.contains("Shutdown phase")).forEach(l -> t.line("%s", l));
+ assertThat(ms).isBetween(450L, 3000L);
+ assertThat(output.getAll()).contains("Shutdown phase 2147483647 ends with 1 bean still running after timeout of 500ms: [neverStops]");
+ }
+ }
+
+ @Test
+ void smartLifecycleBeansInARealWebApplication() {
+ try (var t = new Transcript("15-smartlifecycle-beans-in-boot.txt",
+ "Every SmartLifecycle bean in a Boot web application, highest phase (stops first) at the top")) {
+ try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ShutdownApp.class)
+ .web(WebApplicationType.SERVLET)
+ .properties("server.port=0", "logging.level.root=OFF", "spring.main.banner-mode=off")
+ .run()) {
+ Map beans = ctx.getBeansOfType(SmartLifecycle.class);
+ var sorted = new java.util.ArrayList<>(beans.entrySet());
+ sorted.sort((a, b) -> Integer.compare(b.getValue().getPhase(), a.getValue().getPhase()));
+ t.line("%-14s %-34s %s", "phase", "bean name", "class");
+ for (var e : sorted) {
+ t.line("%-14d %-34s %s", e.getValue().getPhase(), e.getKey(), e.getValue().getClass().getSimpleName());
+ }
+ t.blank();
+ t.line("SmartLifecycle.DEFAULT_PHASE = %d", SmartLifecycle.DEFAULT_PHASE);
+ assertThat(beans).isNotEmpty();
+ }
+ }
+ }
+}
diff --git a/core-beans/src/test/java/com/ankurm/corebeans/ScopesTest.java b/core-beans/src/test/java/com/ankurm/corebeans/ScopesTest.java
new file mode 100644
index 0000000..594c06a
--- /dev/null
+++ b/core-beans/src/test/java/com/ankurm/corebeans/ScopesTest.java
@@ -0,0 +1,221 @@
+package com.ankurm.corebeans;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.net.CookieManager;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.IntSupplier;
+
+import com.ankurm.corebeans.scopes.*;
+import com.ankurm.corebeans.web.BrokenRequestConsumer;
+import com.ankurm.corebeans.web.WebScopesApp;
+import org.junit.jupiter.api.BeforeEach;
+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.mock.web.MockServletContext;
+import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
+
+/** Post 18: scopes, counted. */
+class ScopesTest {
+
+ @BeforeEach
+ void reset() {
+ Instances.reset();
+ Trace.drain();
+ }
+
+ @Test
+ void singletonAndPrototypeCounts() {
+ try (var t = new Transcript("01-instance-counts.txt", "How many instances did the container really construct?")) {
+ try (var ctx = Ctx.plain(SingletonBean.class, LazySingletonBean.class, PrototypeBean.class)) {
+ t.line("right after refresh():");
+ t.line(" singleton instances : %d", Instances.count("singleton"));
+ t.line(" @Lazy singleton : %d", Instances.count("lazy"));
+ t.line(" prototype : %d", Instances.count("prototype"));
+ assertThat(Instances.count("singleton")).isEqualTo(1);
+ assertThat(Instances.count("lazy")).isZero();
+ assertThat(Instances.count("prototype")).isZero();
+
+ var s1 = ctx.getBean(SingletonBean.class);
+ var s2 = ctx.getBean(SingletonBean.class);
+ var l1 = ctx.getBean(LazySingletonBean.class);
+ var l2 = ctx.getBean(LazySingletonBean.class);
+ var p1 = ctx.getBean(PrototypeBean.class);
+ var p2 = ctx.getBean(PrototypeBean.class);
+ var p3 = ctx.getBean(PrototypeBean.class);
+ t.blank();
+ t.line("after getBean() twice for singleton and lazy, three times for prototype:");
+ t.line(" singleton instances : %d same object both times: %s", Instances.count("singleton"), s1 == s2);
+ t.line(" @Lazy singleton : %d same object both times: %s", Instances.count("lazy"), l1 == l2);
+ t.line(" prototype : %d serials: %d, %d, %d", Instances.count("prototype"), p1.serial(), p2.serial(), p3.serial());
+ assertThat(s1).isSameAs(s2);
+ assertThat(Instances.count("prototype")).isEqualTo(3);
+ }
+ Instances.reset();
+ try (var first = Ctx.plain(SingletonBean.class); var second = Ctx.plain(SingletonBean.class)) {
+ t.blank();
+ t.line("two separate contexts, each with SingletonBean:");
+ t.line(" singleton instances : %d same object across contexts: %s", Instances.count("singleton"),
+ first.getBean(SingletonBean.class) == second.getBean(SingletonBean.class));
+ assertThat(Instances.count("singleton")).isEqualTo(2);
+ }
+ }
+ }
+
+ @Test
+ void prototypeDestroyIsNeverCalled() {
+ try (var t = new Transcript("02-prototype-destroy.txt", "@PreDestroy on a singleton and on a prototype, then context.close()")) {
+ var ctx = Ctx.plain(SingletonBean.class, PrototypeBean.class);
+ ctx.getBean(PrototypeBean.class);
+ t.line("prototype instances created: %d", Instances.count("prototype"));
+ ctx.close();
+ var events = Trace.drain();
+ t.line("callbacks after close(): %s", events);
+ assertThat(events).contains("singleton @PreDestroy called").doesNotContain("prototype @PreDestroy called");
+ }
+ }
+
+ @Test
+ void prototypeInsideSingleton() {
+ try (var t = new Transcript("03-prototype-in-singleton.txt",
+ "A singleton calls use() five times. How many prototype instances did it touch?")) {
+ t.line("%-34s %-22s %s", "how the prototype is obtained", "serials seen", "instances built");
+ t.line("%-34s %-22s %s", "-".repeat(34), "-".repeat(22), "-".repeat(15));
+ Object[][] cases = {
+ {"constructor injection (naive)", "prototype", NaiveConsumer.class, PrototypeBean.class},
+ {"ObjectProvider.getObject()", "prototype", ProviderConsumer.class, PrototypeBean.class},
+ {"@Lookup method", "prototype", LookupConsumer.class, PrototypeBean.class},
+ {"scoped proxy (TARGET_CLASS)", "scopedPrototype", ProxyConsumer.class, ScopedPrototypeBean.class},
+ {"ApplicationContext.getBean()", "prototype", ContextConsumer.class, PrototypeBean.class},
+ };
+ String proxyClass = null;
+ for (Object[] c : cases) {
+ Instances.reset();
+ try (var ctx = Ctx.plain((Class>) c[2], (Class>) c[3])) {
+ Object consumer = ctx.getBean((Class>) c[2]);
+ List serials = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ serials.add(use(consumer));
+ }
+ t.line("%-34s %-22s %d", c[0], serials, Instances.count((String) c[1]));
+ if (c[2] == NaiveConsumer.class) {
+ assertThat(Instances.count("prototype")).isEqualTo(1);
+ } else {
+ assertThat(Instances.count((String) c[1])).isEqualTo(5);
+ }
+ if (c[2] == ProxyConsumer.class) {
+ proxyClass = ((ProxyConsumer) consumer).injectedClass();
+ }
+ }
+ }
+ t.blank();
+ t.line("ProxyConsumer's injected reference is a %s", proxyClass);
+ }
+ }
+
+ private static int use(Object consumer) {
+ try {
+ return (Integer) consumer.getClass().getMethod("use").invoke(consumer);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @Test
+ void singletonStateIsSharedAcrossThreads() throws Exception {
+ try (var t = new Transcript("04-singleton-thread-safety.txt",
+ "Two threads call the same singleton; a latch forces the interleaving")) {
+ try (var ctx = Ctx.plain(UnsafeGreeter.class, SafeGreeter.class)) {
+ String unsafe = interleave(ctx.getBean(UnsafeGreeter.class)::greet);
+ String safe = interleave(ctx.getBean(SafeGreeter.class)::greet);
+ t.line("UnsafeGreeter (state in a field) : alice's call returned \"%s\"", unsafe);
+ t.line("SafeGreeter (state in a local) : alice's call returned \"%s\"", safe);
+ assertThat(unsafe).isEqualTo("Hello, bob");
+ assertThat(safe).isEqualTo("Hello, alice");
+ }
+ }
+ }
+
+ /** Alice sets her name and pauses; Bob sets his name meanwhile; Alice then reads. Returns Alice's result. */
+ private static String interleave(java.util.function.BiFunction greet) throws Exception {
+ CountDownLatch bobHasSet = new CountDownLatch(1);
+ String[] aliceResult = new String[1];
+ Thread alice = Thread.ofPlatform().start(() -> aliceResult[0] = greet.apply("alice", () -> {
+ try {
+ bobHasSet.await(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }));
+ Thread bob = Thread.ofPlatform().start(() -> greet.apply("bob", bobHasSet::countDown));
+ alice.join();
+ bob.join();
+ return aliceResult[0];
+ }
+
+ @Test
+ void webScopes() throws Exception {
+ try (var t = new Transcript("05-web-scopes.txt", "request, session and application scope over real HTTP")) {
+ try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(WebScopesApp.class)
+ .web(WebApplicationType.SERVLET)
+ .properties("server.port=0", "logging.level.root=OFF", "spring.main.banner-mode=off")
+ .run()) {
+ int port = Integer.parseInt(ctx.getEnvironment().getProperty("local.server.port"));
+ URI uri = URI.create("http://localhost:" + port + "/scopes");
+ var clientA = HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
+ var clientB = HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
+ t.line("%-10s %s", "client", "response");
+ String lastA = null;
+ for (int i = 1; i <= 3; i++) {
+ lastA = get(clientA, uri);
+ t.line("%-10s %s", "A #" + i, lastA);
+ }
+ for (int i = 1; i <= 2; i++) {
+ t.line("%-10s %s", "B #" + i, get(clientB, uri));
+ }
+ t.blank();
+ t.line("instances constructed: request=%d session=%d application=%d", Instances.count("request"),
+ Instances.count("session"), Instances.count("application"));
+ assertThat(Instances.count("request")).isEqualTo(5);
+ assertThat(Instances.count("session")).isEqualTo(2);
+ assertThat(Instances.count("application")).isEqualTo(1);
+ assertThat(lastA).contains("session=1").contains("application=1");
+ }
+ }
+ }
+
+ private static String get(HttpClient client, URI uri) throws Exception {
+ return client.send(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString()).body();
+ }
+
+ @Test
+ void requestScopeWithoutProxyFailsAtStartup() {
+ try (var t = new Transcript("06-request-scope-without-proxy.txt",
+ "A request-scoped bean (no proxy) injected into a singleton")) {
+ var ctx = new AnnotationConfigWebApplicationContext();
+ ctx.setServletContext(new MockServletContext());
+ ctx.register(BrokenRequestConsumer.RawRequestBean.class, BrokenRequestConsumer.class);
+ try {
+ ctx.refresh();
+ t.line("context started (unexpected)");
+ assertThat(false).isTrue();
+ } catch (RuntimeException e) {
+ t.line("top exception : %s", e.getClass().getName());
+ t.line("top message : %s", e.getMessage());
+ t.blank();
+ t.line("root cause : %s", Ctx.root(e));
+ } finally {
+ ctx.close();
+ }
+ }
+ }
+}
diff --git a/core-beans/src/test/java/com/ankurm/corebeans/Transcript.java b/core-beans/src/test/java/com/ankurm/corebeans/Transcript.java
new file mode 100644
index 0000000..3b9141d
--- /dev/null
+++ b/core-beans/src/test/java/com/ankurm/corebeans/Transcript.java
@@ -0,0 +1,52 @@
+package com.ankurm.corebeans;
+
+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);
+ }
+}