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,66 @@
#!/usr/bin/env bash
# Config trees: what a Kubernetes ConfigMap or Secret actually looks like to Spring Boot.
#
# A ConfigMap mounted as a volume is not a properties file. Kubernetes writes one file per
# key, named after the key, containing only the value. `configtree:` is the loader that reads
# that shape. This script builds the same directory layout on disk, so the demonstration is
# the real mechanism rather than a description of it.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
TREE="${TMPDIR:-/tmp}/demo-configmap"
SECRET="${TMPDIR:-/tmp}/demo-secret"
rm -rf "$TREE" "$SECRET"; mkdir -p "$TREE" "$SECRET"
# Exactly what `kubectl create configmap demo --from-literal=demo.greeting=...` produces
# once mounted: one file per key, the filename IS the property name.
printf 'from-configmap-volume' > "$TREE/demo.greeting"
printf 'jdbc:postgresql://configmap-db:5432/o' > "$TREE/demo.datasource-url"
printf '25' > "$TREE/demo.pool-size"
# Nested keys use a directory per level, or a dotted filename. Both work.
mkdir -p "$TREE/demo/nested"
printf 'from-nested-directory' > "$TREE/demo/nested/value"
# A Secret mount looks identical; only the permissions differ.
printf 'sk_live_not_a_real_key' > "$SECRET/demo.api-key"
{
echo "== what Kubernetes actually mounts =="
echo "\$ find $TREE $SECRET -type f | sort"
find "$TREE" "$SECRET" -type f | sort | sed "s|$TREE|<configmap-mount>|;s|$SECRET|<secret-mount>|"
echo
echo "\$ cat <configmap-mount>/demo.greeting; echo"
cat "$TREE/demo.greeting"; echo
echo
echo "Each file holds a bare value with no trailing newline and no key. There is no"
echo "properties syntax to parse -- the filename is the key."
echo
echo "== importing it =="
echo "\$ java -jar $JAR \\"
echo " --spring.config.import=configtree:$TREE/,configtree:$SECRET/"
echo
start_app "--spring.config.import=configtree:$TREE/,configtree:$SECRET/" > /dev/null
report demo.greeting
echo
echo " demo.pool-size = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.pool-size" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')"
echo " demo.nested.value = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.nested.value" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')"
echo " demo.api-key = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.api-key" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')"
echo
echo "A directory under the mount becomes a nested property: demo/nested/value is"
echo "demo.nested.value. That is how a ConfigMap with slashes in its keys arrives."
echo
echo "== the part that surprises people =="
echo "An imported config tree outranks application.yaml, but it is still config data,"
echo "so it still loses to an environment variable:"
echo
APP_ENV="DEMO_GREETING=from-environment-variable" \
start_app "--spring.config.import=configtree:$TREE/" > /dev/null
report demo.greeting
echo
echo "There is also no such thing as a profile-specific config tree. There is no"
echo "<mount>-prod directory convention; a per-environment ConfigMap is a different mount"
echo "chosen by the deployment, not by spring.profiles.active."
stop_app
} > docs/output/03-config-tree.txt 2>&1
cat docs/output/03-config-tree.txt

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# spring.config.import ordering, multi-document activation, and the activation Boot refuses.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== spring.config.import: which document wins? =="
echo
echo "application-import.yaml imports imported.yaml. Both set demo.greeting."
echo "\$ java -jar $JAR --spring.profiles.active=import"
echo
start_app --spring.profiles.active=import > /dev/null
report demo.greeting
echo
echo " demo.imported-only = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.imported-only" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')"
echo
echo "The imported file WON. spring.config.import does not behave like #include, and it"
echo "does not behave like a default either: the imported document is processed AFTER the"
echo "document that declared the import, so it outranks the file that pulled it in."
echo "If you import a shared baseline expecting your own file to override it, every key"
echo "the baseline sets will quietly beat yours."
echo
echo "== one file, several documents, activated by condition =="
for profile in "" staging prod; do
label="${profile:-<none>}"
echo "--- spring.profiles.active=$label (with the multidoc profile) ---"
if [ -z "$profile" ]; then
start_app --spring.profiles.active=multidoc > /dev/null
else
start_app --spring.profiles.active="multidoc,$profile" > /dev/null
fi
report demo.greeting
echo
done
echo "Later documents in the same file win over earlier ones, so the unconditional first"
echo "document acts as the default and each conditional document overrides it."
echo
echo "== the activation Spring Boot refuses =="
echo "application-badactivation.yaml tries to set spring.profiles.active from a document"
echo "that is itself conditional on a profile."
echo "\$ java -jar $JAR --spring.profiles.active=badactivation,staging"
echo
stop_app
java -jar "$JAR" --spring.profiles.active=badactivation,staging 2>&1 | clean \
| grep -E 'InvalidConfigDataPropertyException' | head -2 | fold -s -w 96
echo
echo
echo "InvalidConfigDataPropertyException, naming the file and the line. Boot refuses"
echo "rather than half-applying it: a profile that activates itself would change which"
echo "files are loaded after those files had already been chosen."
} > docs/output/04-import-and-multidoc.txt 2>&1
cat docs/output/04-import-and-multidoc.txt

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# The whole precedence question, asked once with every source set at the same time.
#
# demo.greeting is set by application.yaml, by application-prod.yaml, by an environment
# variable, by a system property and by a command-line argument -- simultaneously. The
# endpoint reports all of them in order, so the winner is not a matter of opinion.
set -euo pipefail
set +m # no job-control notices ("Killed") in the captured transcript
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== every source sets demo.greeting at once =="
echo
echo "\$ DEMO_GREETING=from-environment-variable \\"
echo " java -Ddemo.greeting=from-system-property \\"
echo " -jar $JAR --spring.profiles.active=prod \\"
echo " --demo.greeting=from-command-line-argument"
echo
scripts/stop.sh
DEMO_GREETING=from-environment-variable setsid nohup java \
-Ddemo.greeting=from-system-property -jar "$JAR" \
--spring.profiles.active=prod --demo.greeting=from-command-line-argument \
> /tmp/profiles-precedence.log 2>&1 < /dev/null &
echo $! > target/app.pid
for _ in $(seq 1 60); do
curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/precedence" && break; sleep 1; done
curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.greeting" | python3 -m json.tool
echo
echo "== and with the environment variable removed, nothing else changed =="
scripts/stop.sh
setsid nohup java -Ddemo.greeting=from-system-property -jar "$JAR" \
--spring.profiles.active=prod > /tmp/profiles-precedence2.log 2>&1 < /dev/null &
echo $! > target/app.pid
for _ in $(seq 1 60); do
curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/precedence" && break; sleep 1; done
curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.greeting" | python3 -m json.tool
scripts/stop.sh
} > docs/output/01-precedence.txt 2>&1
cat docs/output/01-precedence.txt

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# The article's title question: why did application-prod.yaml have no effect?
#
# Because an environment variable was set. Profile-specific files beat non-profile files,
# but the whole config-data group sits BELOW environment variables in the documented
# precedence list, so a profile file never outranks one.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== does application-prod.yaml win? =="
echo
echo "demo.datasource-url is set in application.yaml and again in application-prod.yaml."
echo
echo "--- 1. prod profile active, no environment variable ---"
echo "\$ java -jar $JAR --spring.profiles.active=prod"
start_app --spring.profiles.active=prod > /dev/null
report demo.datasource-url
echo
echo "--- 2. identical, plus one leftover environment variable ---"
echo "\$ DEMO_DATASOURCE_URL=jdbc:postgresql://leftover:5432/orders \\"
echo " java -jar $JAR --spring.profiles.active=prod"
APP_ENV="DEMO_DATASOURCE_URL=jdbc:postgresql://leftover:5432/orders" \
start_app --spring.profiles.active=prod > /dev/null
report demo.datasource-url
echo
echo "The profile-specific file is still loaded and still holds its value -- it is listed,"
echo "and it lost. Config data is item 3 in the documented precedence list; OS environment"
echo "variables are item 5, and later items win."
echo
echo "== the full property-source stack, in order =="
echo "\$ curl -s localhost:8080/sources"
curl -s "http://127.0.0.1:${APP_PORT}/sources" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
print(" %2d. %-34s %s" % (r["rank"], r["type"], r["name"][:110]))'
stop_app
} > docs/output/02-profile-file-loses.txt 2>&1
cat docs/output/02-profile-file-loses.txt

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
set +m # no job-control notices ("Killed") in the captured transcript
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== versions =="
java -version 2>&1 | clean
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,76 @@
#!/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/profiles-and-config-1.0.0.jar"
APP_MAIN="com.ankurm.profiles.ProfilesApplication"
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"; }
# Start the demo jar detached, record its PID, and block until it answers.
# Extra arguments are passed to the application. Environment variables for the run are
# passed by setting them on the call: `APP_ENV="A=1 B=2" start_app --spring.profiles.active=x`
start_app() {
stop_app
mkdir -p target
# Deliberately NOT setsid: setsid forks when it is not already a process-group leader,
# so $! would be the PID of a process that exits immediately and the JVM would survive
# every later stop_app. A surviving JVM keeps the port, the next scenario fails to bind,
# and curl answers from the previous scenario -- which reads exactly like the
# configuration change under test having had no effect. Three wrong findings in this
# repository came from that before it was tracked down.
if [ -n "${APP_ENV:-}" ]; then
# shellcheck disable=SC2086
env $APP_ENV nohup java -jar "$JAR" "$@" > /tmp/profiles-demo.log 2>&1 < /dev/null &
else
nohup java -jar "$JAR" "$@" > /tmp/profiles-demo.log 2>&1 < /dev/null &
fi
echo $! > target/app.pid
for _ in $(seq 1 60); do
curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/precedence" 2>/dev/null && return 0
kill -0 "$(cat target/app.pid)" 2>/dev/null || { echo "JVM exited during startup:"
tail -20 /tmp/profiles-demo.log; return 1; }
sleep 1
done
echo "application did not answer"; tail -20 /tmp/profiles-demo.log; return 1
}
# Stop it by recorded PID. Never by pattern: `ps | grep <jar name>` also matches the shell
# running the script, because the jar name is on that shell's own command line.
stop_app() {
if [ -f target/app.pid ]; then
pid=$(cat target/app.pid)
if [ -n "$pid" ] && grep -qa "profiles-and-config" "/proc/$pid/cmdline" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true # reap, so bash prints no "Killed" notice
fi
rm -f target/app.pid
fi
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
}
# Print the precedence report for one property, compactly.
report() {
curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=$1" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print(" active profiles :", ", ".join(d["activeProfiles"]) or "(none)")
print(" effective value :", d["effectiveValue"])
for h in d["holders"]:
src=h["source"]
for noisy,short in (("Config resource \x27class path resource [","file "),
("\x27 via location \x27optional:classpath:/\x27}","")):
src=src.replace(noisy,short)
src=src.replace("OriginTrackedMapPropertySource {name=","").replace("]","")
print(" %d. %-34s <- %s" % (h["rank"], h["value"], src.strip()))
print(" holders that lost:", d["shadowedCount"])'
}

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 precedence profile-file-loses config-tree import-and-multidoc; do
echo "=== $demo ==="
"scripts/demo-$demo.sh" > /dev/null
done
stop_app
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}/precedence" || 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 "profiles-and-config" "/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