1
0

Three new article modules: configuration binding, profiles and config data, Spring AOP

configuration-properties/  @ConfigurationProperties vs @Value on Spring Boot 4.1.1.
  The relaxed-binding matrix is generated by binding each spelling rather than
  transcribed, and re-checked against real processes -- the in-process probe was
  wrong twice before it was right. Records the three findings that came out of it:
  @Value does get relaxed resolution inside Spring Boot (Boot attaches
  ConfigurationPropertySources), the configuration processor silently stops
  generating metadata on JDK 23+ when declared as a plain dependency, and @Valid is
  not what makes nested constraints run.

profiles-and-config/       Precedence, profiles, spring.config.import and config trees.
  /precedence reports every source holding a property in rank order with file and
  line, which turns "my profile file had no effect" into a two-line answer. Also
  pins the counterintuitive one: an imported file outranks the file that imported it.

spring-aop/                Designators, proxy types, and aspects that do not fire.
  One advice per supported designator so the reference table is generated from real
  matches; all fourteen unsupported designators fed to the parser. Two corrections to
  the reference documentation: unsupported designators throw
  UnsupportedPointcutPrimitiveException (extends RuntimeException, not
  IllegalArgumentException), and spring-boot-starter-aop was renamed to
  spring-boot-starter-aspectj in Boot 4.

19 contract tests across the three modules, 15 captured transcripts, all regenerated
by scripts/run-all.sh. Verified on Spring Boot 4.1.1, Spring Framework 7.0.9,
JDK 25.0.4.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gip4srpzMwjgoba6uEfbr5
This commit is contained in:
2026-09-08 16:36:17 +00:00
parent 958b401f0f
commit 86246dc860
107 changed files with 5075 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# The matrix re-asked the expensive way: one real JVM per spelling, the value supplied by the
# operating system or by -D, and read back through both @ConfigurationProperties and @Value.
#
# This script exists because the in-process matrix was wrong twice before it was right. A
# synthesised property source is not a running Spring Boot application, and when the two
# disagree the running application wins.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
RUN=(java -jar "$JAR" --spring.profiles.active=envprobe
--spring.main.web-application-type=none --spring.main.banner-mode=off
--logging.level.root=OFF)
run_env() {
echo "\$ $1=secret-value java -jar $JAR"
env -u DEMO_RELAXED_API_KEY -u DEMO_RELAXED_APIKEY "$1=secret-value" "${RUN[@]}" 2>&1 \
| clean | grep -E "^(env-var-set|@Config|@Value|Environment)"
echo
}
run_sysprop() {
echo "\$ java -D$1=secret-value -jar $JAR"
env -u DEMO_RELAXED_API_KEY -u DEMO_RELAXED_APIKEY \
java "-D$1=secret-value" -jar "$JAR" --spring.profiles.active=envprobe \
--spring.main.web-application-type=none --spring.main.banner-mode=off \
--logging.level.root=OFF 2>&1 \
| clean | grep -E "^(@Config|@Value|Environment)"
echo
}
{
echo "== real process, real environment: binding demo.relaxed.api-key =="
echo
echo "canonical property : demo.relaxed.api-key"
echo "(MISS) means the property was not found and the declared default was used"
echo
echo "--- as an operating-system environment variable ---"
echo
run_env DEMO_RELAXED_API_KEY
run_env DEMO_RELAXED_APIKEY
echo "--- as a JVM system property (-D), i.e. the 'map' rows of the matrix ---"
echo
run_sysprop demo.relaxed.api-key
run_sysprop demo.relaxed.apiKey
run_sysprop demo.relaxed.apikey
run_sysprop demo.relaxed.api.key
echo "The last one is not a spelling of the property. api.key is two name elements;"
echo "api-key is one. Nothing relaxed will ever join them."
} > docs/output/02-env-var-binding.txt 2>&1
cat docs/output/02-env-var-binding.txt

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Does spring-boot-configuration-processor actually run?
#
# Two builds of the SAME sources with the SAME processor jar, differing only in how the
# processor is declared to the compiler. On JDK 23+ that difference decides whether
# META-INF/spring-configuration-metadata.json exists at all -- and neither build fails.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
WORK="${TMPDIR:-/tmp}/configprops-metadata-ab"
rm -rf "$WORK"; mkdir -p "$WORK/a" "$WORK/b"
PROC_JAR=$(find ~/.m2/repository/org/springframework/boot/spring-boot-configuration-processor \
-name 'spring-boot-configuration-processor-*.jar' | sort | tail -1)
CP=$("$MVN" -B -o -q dependency:build-classpath -Dmdep.outputFile=/dev/stdout 2>/dev/null | tail -1)
SOURCES=$(find src/main/java -name '*.java')
{
echo "== is the annotation processor discovered? =="
echo
java -version 2>&1 | clean | head -1
echo "processor jar: $(basename "$PROC_JAR")"
echo
echo "A) processor on the classpath, javac defaults -- what an <optional> dependency gives you"
echo "\$ javac -cp <deps>:spring-boot-configuration-processor.jar -d a \$SOURCES"
javac -nowarn -cp "$PROC_JAR:$CP" -d "$WORK/a" $SOURCES 2>&1 | clean | head -3 || true
echo " spring-configuration-metadata.json files produced: \
$(find "$WORK/a" -name 'spring-configuration-metadata.json' | wc -l)"
echo
echo "B) identical, plus -proc:full"
echo "\$ javac -proc:full -cp <deps>:spring-boot-configuration-processor.jar -d b \$SOURCES"
javac -nowarn -proc:full -cp "$PROC_JAR:$CP" -d "$WORK/b" $SOURCES 2>&1 | clean | head -3 || true
echo " spring-configuration-metadata.json files produced: \
$(find "$WORK/b" -name 'spring-configuration-metadata.json' | wc -l)"
echo
echo "Both compilations succeed. Only one of them has metadata."
echo
echo "== what this project's pom does instead =="
echo "The processor is declared as an <annotationProcessorPath> on maven-compiler-plugin,"
echo "which puts it on javac's --processor-path where discovery is not disabled:"
echo
echo "\$ mvn clean package && ls target/classes/META-INF/"
ls target/classes/META-INF/ 2>/dev/null || echo "(run mvn package first)"
echo
echo "== the generated metadata, first entries =="
python3 -m json.tool target/classes/META-INF/spring-configuration-metadata.json 2>/dev/null \
| head -45
} > docs/output/05-metadata-generation.txt 2>&1
cat docs/output/05-metadata-generation.txt

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# The relaxed-binding matrix, generated by binding each spelling rather than by hand.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
java -jar "$JAR" --spring.profiles.active=probe --spring.main.web-application-type=none \
2>&1 | clean | sed -n '/== relaxed binding matrix ==/,/MISS = the property/p' \
> docs/output/01-relaxed-matrix.txt
cat docs/output/01-relaxed-matrix.txt

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# What a failing @Validated @ConfigurationProperties actually prints at startup.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== startup with demo.validated.* deliberately out of range =="
echo "\$ java -jar $JAR --spring.profiles.active=badvalidation"
echo
java -jar "$JAR" --spring.profiles.active=badvalidation \
--spring.main.web-application-type=none 2>&1 | clean \
| sed -n '/APPLICATION FAILED TO START/,/^Update your application/p'
echo
echo
echo "All four violations are reported at once, each with the file and line that supplied"
echo "the value. The process refused to start rather than serving traffic with a pool size"
echo "of 4000."
echo
echo "Note demo.validated.pool.size has no Origin line. It is a nested record reached"
echo "through @Valid, and the binder tracks origins per bound property, not per constraint."
} > docs/output/04-validation-failure.txt 2>&1
cat docs/output/04-validation-failure.txt

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Side by side: what the binder produced and what @Value produced, from one running process.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== @ConfigurationProperties vs @Value, same application, same application.yaml =="
echo
scripts/run.sh > /dev/null
echo "\$ curl -s localhost:8080/diag/bound"
curl -s "http://127.0.0.1:${APP_PORT}/diag/bound" | python3 -m json.tool
echo
echo "demo.mail.recipients is a YAML block list. The binder produced both elements."
echo "@Value produced an empty list -- placeholder resolution has no concept of a YAML"
echo "sequence, so \${demo.mail.recipients:} fell through to its own empty default."
echo
echo "== the same list written as a comma-separated string, at higher precedence =="
scripts/run.sh "" --demo.mail.recipients=x@e.com,y@e.com,z@e.com > /dev/null
echo "\$ ./scripts/run.sh \"\" --demo.mail.recipients=x@e.com,y@e.com,z@e.com"
curl -s "http://127.0.0.1:${APP_PORT}/diag/bound" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(" binder :", d["mail"]["recipients"]); print(" @Value :", d["fromValueAnnotation"]["recipients"])'
echo
echo "Both see it. A comma-separated string is the one list shape @Value understands, and"
echo "the command-line source outranks the YAML file for the binder as well."
echo
echo "== where did that value come from? =="
echo "\$ curl -s 'localhost:8080/diag/origin?name=demo.mail.recipients'"
curl -s "http://127.0.0.1:${APP_PORT}/diag/origin?name=demo.mail.recipients" | python3 -m json.tool
echo
echo "\$ curl -s 'localhost:8080/diag/origin?name=demo.mail.host'"
curl -s "http://127.0.0.1:${APP_PORT}/diag/origin?name=demo.mail.host" | python3 -m json.tool
scripts/stop.sh
} > docs/output/03-value-vs-binding.txt 2>&1
cat docs/output/03-value-vs-binding.txt

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Exact versions every other transcript in this directory was produced against.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== versions =="
java -version 2>&1 | clean
echo
"$MVN" -B -o -q dependency:tree 2>/dev/null \
| grep -E "spring-boot:jar|spring-core:jar|spring-context:jar|hibernate-validator:jar|jakarta.validation-api:jar" \
| sed 's/^\[INFO\] //' || true
echo
echo "spring-boot-starter-parent: $(grep -A2 '<artifactId>spring-boot-starter-parent' pom.xml | grep '<version>' | sed 's/.*<version>\(.*\)<\/version>.*/\1/')"
} > docs/output/00-versions.txt 2>&1
cat docs/output/00-versions.txt

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Shared environment. Point JAVA_HOME at a JDK 25 (or newer) installation.
: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}"
export PATH="$JAVA_HOME/bin:$PATH"
MVN="${MVN:-mvn}"
JAR="target/configuration-properties-1.0.0.jar"
APP_MAIN="com.ankurm.configprops.ConfigBindingApplication"
APP_PORT="${APP_PORT:-8080}"
# Strip environment noise that is an artefact of the machine, not of Spring:
# the JVM prints a JAVA_TOOL_OPTIONS banner to stderr on every launch when a proxy
# truststore is configured, and it would otherwise end up in every committed transcript.
clean() { grep -v "Picked up JAVA_TOOL_OPTIONS" | grep -v "^OpenJDK 64-Bit Server VM warning"; }

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Regenerate every transcript under docs/output/.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
"$MVN" -B -q package -DskipTests
for demo in versions relaxed-matrix env-binding value-vs-binding validation metadata-generation; do
echo "=== $demo ==="
"scripts/demo-$demo.sh" > /dev/null
done
scripts/stop.sh
echo
echo "regenerated:"
ls -1 docs/output/

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Start the application and block until it answers. Extra arguments are passed to the app,
# so a scenario can add --demo.mail.recipients=a,b,c without a new profile.
# ./scripts/run.sh # defaults
# ./scripts/run.sh csvlist # a profile
# ./scripts/run.sh "" --demo.x=y # no profile, one override
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
PROFILES="${1:-}"; shift || true
LOG="${LOG:-/tmp/configprops-demo.log}"
PIDFILE="${PIDFILE:-target/app.pid}"
scripts/stop.sh
ARGS=(-jar "$JAR")
[ -n "$PROFILES" ] && ARGS+=("--spring.profiles.active=$PROFILES")
ARGS+=("$@")
setsid nohup java "${ARGS[@]}" > "$LOG" 2>&1 < /dev/null &
echo $! > "$PIDFILE"
for _ in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${APP_PORT}/diag/bound" || true)
[ "$code" = "200" ] && exit 0
# If the JVM died -- most often because the port was still held -- fail fast and loudly
# instead of letting curl answer from a process started by an earlier scenario.
kill -0 "$(cat "$PIDFILE")" 2>/dev/null || { echo "JVM exited during startup:" >&2
tail -25 "$LOG" >&2; exit 1; }
sleep 1
done
echo "application did not answer; tail of $LOG:" >&2
tail -40 "$LOG" >&2
exit 1

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Stop the demo application.
#
# This uses a PID file rather than a pattern match, deliberately. `pkill -f spring-boot`
# matches the shell that is running the script and takes the terminal with it. Even a
# careful-looking `ps | grep '[c]onfiguration-properties'` matches the shell's own command
# line whenever that string appears in the command you just typed -- which it does, because
# you typed the jar name. Killing a recorded PID cannot misfire.
set -u
cd "$(dirname "$0")/.."
PIDFILE="${PIDFILE:-target/app.pid}"
if [ -f "$PIDFILE" ]; then
pid=$(cat "$PIDFILE")
# Confirm the PID is still ours before signalling it: PIDs are reused.
if [ -n "$pid" ] && grep -qa "configuration-properties" "/proc/$pid/cmdline" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$PIDFILE"
fi
# Killing the process is not the same as the socket closing, and a stale listener looks
# exactly like your configuration change having had no effect.
for _ in $(seq 1 40); do
if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT:-8080}") 2>/dev/null; then break; fi
sleep 0.25
done
exec 3<&- 2>/dev/null || true