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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gip4srpzMwjgoba6uEfbr5
This commit is contained in:
2026-09-08 16:47:48 +00:00
co-authored by Claude Opus 5
parent 958b401f0f
commit 86246dc860
107 changed files with 5075 additions and 0 deletions
@@ -0,0 +1,46 @@
package com.ankurm.configprops.props;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* Constructor binding with a record &mdash; the shape this article recommends.
*
* <p>A record has exactly one canonical constructor, so Spring Boot uses constructor binding
* without any annotation. {@code @ConstructorBinding} is only needed to disambiguate when a
* type has more than one candidate constructor.
*
* <p>A record cannot declare field initialisers, so "default value" has to be expressed with
* {@link DefaultValue}. Leaving it off does not give you {@code null} for a primitive-like
* type &mdash; it gives you a binding failure. See {@code docs/04-records-and-defaults.md}.
*
* @param host SMTP host. No default: absent means the application must not start.
* @param port SMTP port, defaulted rather than left null.
* @param timeout Bound from an ISO-8601 duration or a suffixed form such as {@code 30s}.
* @param retries Nested record, bound from {@code demo.mail.retries.*}.
* @param recipients Bound from a YAML list, an indexed property list, or a comma-separated string.
* @param headers Bound from arbitrary sub-keys under {@code demo.mail.headers.*}.
*/
@ConfigurationProperties(prefix = "demo.mail")
public record MailProperties(
String host,
@DefaultValue("587") int port,
@DefaultValue("30s") Duration timeout,
@DefaultValue RetryProperties retries,
@DefaultValue List<String> recipients,
@DefaultValue Map<String, String> headers) {
/**
* Nested record. {@code @DefaultValue} on the enclosing component means this is
* instantiated with its own defaults when {@code demo.mail.retries.*} is absent entirely,
* rather than binding to {@code null}.
*/
public record RetryProperties(
@DefaultValue("3") int maxAttempts,
@DefaultValue("2s") Duration backoff) {
}
}
@@ -0,0 +1,16 @@
package com.ankurm.configprops.props;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* One property, bound by the relaxed binder, used to prove which spellings actually work.
*
* <p>The canonical form of this property is {@code demo.relaxed.api-key}. The relaxed binder
* accepts several spellings of that name; {@code @Value} accepts exactly one. The matrix in
* {@code docs/02-relaxed-binding.md} is generated by
* {@code com.ankurm.configprops.web.RelaxedBindingProbe}, not written by hand.
*/
@ConfigurationProperties(prefix = "demo.relaxed")
public record RelaxedProperties(@DefaultValue("(unset)") String apiKey) {
}
@@ -0,0 +1,40 @@
package com.ankurm.configprops.props;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.validation.annotation.Validated;
/**
* Validation is the capability {@code @Value} does not have at all.
*
* <p>{@code @Validated} plus JSR-380 constraints turns a bad value into a startup failure with
* a readable report, instead of a {@code NumberFormatException} somewhere in a request three
* hours later. The failure text this produces is captured in
* {@code docs/output/04-validation-failure.txt}.
*
* <p>{@code @Valid} on the nested component is written here because it is conventional, but
* on this path it is not what makes the nested constraints run. Spring Boot's
* {@code ValidationBindHandler} validates every object the binder finishes constructing, so
* {@code demo.validated.pool.size} is checked with or without it. The rule that nested types
* need {@code @Valid} comes from ordinary bean validation, where cascading is opt-in, and it
* gets repeated about {@code @ConfigurationProperties} where it does not apply.
* {@code BindingContractTests.nestedConstraintsFireWithoutValid} pins this. See
* {@code docs/05-validation.md}.
*/
@Validated
@ConfigurationProperties(prefix = "demo.validated")
public record ValidatedProperties(
@NotBlank String name,
@Min(1) @Max(65535) int port,
@Pattern(regexp = "https?://.*") @DefaultValue("http://localhost") String endpoint,
@Valid @DefaultValue Pool pool) {
public record Pool(@Min(1) @Max(100) @DefaultValue("10") int size) {
}
}