Spring Boot Basics ended with a working Order Management API. This course starts where that left off: the parts of Spring Boot you reach for once the application works and the questions become how does this actually behave, how do I extend it, and how do I make it reusable across every service the team owns. It is written for someone who has finished the Basics course or writes Spring Boot professionally — you are expected to know beans, configuration binding, REST, data access, security and testing, and to want the layer underneath them.
The first subject is auto-configuration, from the producing side. Basics article 10 opened the box: how Boot builds its candidate list, what the @ConditionalOn* family decides, how back-off works, and how to read the --debug report. None of that is repeated here. This article is about writing one — a real starter, with a library module, a starter module, conditions, typed settings, ordering against Boot's own classes, tests, a published artifact and an application that consumes it.
![]()
The examples use Spring Boot 4.1.1 and Java 21. Auto-configuration is a topic full of advice from the spring.factories era that has not worked since Boot 3, so this article shows what Boot 4 actually does.
What we are building, and why this example
The starter gives every HTTP request an id. It reads an incoming header if the caller supplied one, generates a prefixed id if not, puts the id in the SLF4J MDC so every log line carries it, echoes it on the response, and adds it to the body of every error response. Three things vary between services — the header name, the paths to skip, and the prefix that identifies the service — and all three are configuration.
That is the shape of a thing worth packaging: identical behaviour in every service, a handful of knobs, and no reason for anyone to write it twice. It is also small enough that the whole of it fits in one article while still exercising every mechanism a real starter needs — conditions, typed settings, ordering against a Boot auto-configuration, a custom condition, and a failure message.
The three Gradle projects
The library and the starter are two modules of one Gradle build; the application is a separate Initializr project that resolves the published artifact.
request-id/
├── settings.gradle
├── build.gradle
├── request-id-spring-boot-autoconfigure/
│ ├── build.gradle
│ └── src/main/
│ ├── java/com/example/requestid/
│ │ ├── RequestIdFilter.java
│ │ ├── RequestIdProperties.java
│ │ ├── RequestIdErrorAttributes.java
│ │ └── autoconfigure/
│ │ ├── RequestIdAutoConfiguration.java
│ │ ├── OnMissingTracingCondition.java
│ │ ├── RequestIdFailureAnalyzer.java
│ │ └── RequestIdServiceNameMissingException.java
│ └── resources/META-INF/
│ ├── spring.factories
│ └── spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
└── request-id-spring-boot-starter/
└── build.gradle
The naming rule is not a convention you can bend. spring-boot-starter-* is reserved for starters the Spring Boot team publishes; anything else takes the form <name>-spring-boot-starter, with its library module named <name>-spring-boot-autoconfigure. The reason is practical rather than legal: a developer reading a dependency list uses the prefix to tell at a glance which artifacts are supported by the Boot team and which are not.
The split between the two modules is also not cosmetic. The autoconfigure module holds all the code and all the conditions. The starter module holds no code at all — it exists so that a consumer writes one dependency line and gets the library plus everything the library needs at run time. Keeping them apart lets someone who wants the code without the opinion depend on the autoconfigure module directly.
rootProject.name = 'request-id'
include 'request-id-spring-boot-autoconfigure'
include 'request-id-spring-boot-starter'The root build configures both modules. Note what it does not do: it never applies the org.springframework.boot plugin. That plugin's job is to build an executable application, and a library is not one.
subprojects {
apply plugin: 'java-library'
apply plugin: 'maven-publish'
group = 'com.example'
version = '0.0.1'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
api platform('org.springframework.boot:spring-boot-dependencies:4.1.1')
annotationProcessor platform('org.springframework.boot:spring-boot-dependencies:4.1.1')
}
publishing {
publications {
maven(MavenPublication) {
from components.java
}
}
}
tasks.named('test') {
useJUnitPlatform()
testLogging {
events 'passed', 'failed'
}
}
}platform('org.springframework.boot:spring-boot-dependencies:4.1.1') is how a library gets Boot's managed versions without the Boot plugin: every Boot and Spring coordinate below can then be written without a version. The highlighted line is there because the first attempt did not have it, and the build failed with a message worth recognising:
> Could not resolve all files for configuration ':request-id-spring-boot-autoconfigure:annotationProcessor'.
> Could not find org.springframework.boot:spring-boot-configuration-processor:.The empty version after the final colon is the tell. A platform applies to the configuration it is declared on and the ones that extend it; annotationProcessor extends nothing, so it needs its own line.
The autoconfigure module is where the interesting dependency decisions are:
dependencies {
api 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.slf4j:slf4j-api'
compileOnly 'jakarta.servlet:jakarta.servlet-api'
compileOnly 'org.springframework:spring-web'
compileOnly 'org.springframework.boot:spring-boot-webmvc'
compileOnly 'jakarta.validation:jakarta.validation-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
annotationProcessor 'org.springframework.boot:spring-boot-autoconfigure-processor'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc'
testImplementation 'org.springframework.boot:spring-boot-starter-validation'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}Everything web-related is compileOnly. The code needs OncePerRequestFilter and DefaultErrorAttributes to compile, but a consumer that is not a servlet web application must not be forced to pull Tomcat in because it depended on a request-id starter. @ConditionalOnClass then makes the absence harmless at run time, and the test suite proves it. The servlet API is compileOnly for the usual reason: the container provides it.
The starter module is the whole reason the naming rule exists, and it is four lines:
dependencies {
api project(':request-id-spring-boot-autoconfigure')
api 'org.springframework.boot:spring-boot-starter'
api 'org.springframework.boot:spring-boot-starter-validation'
}spring-boot-starter-validation is a deliberate choice rather than an oversight: the starter promises that bad configuration fails at startup rather than at the first request, and that promise needs a JSR-303 implementation on the classpath. It is the one dependency this starter adds on the consumer's behalf, and the article says so out loud because a starter that quietly drags in a web stack is the thing everybody complains about.
The published jar proves the module holds nothing:
unzip -l ~/.m2/repository/com/example/request-id-spring-boot-starter/0.0.1/request-id-spring-boot-starter-0.0.1.jar Length Date Time Name
--------- ---------- ----- ----
0 02-01-1980 00:00 META-INF/
25 02-01-1980 00:00 META-INF/MANIFEST.MF
--------- -------
25 2 filesTwenty-five bytes of manifest. Boot's own starters are the same: the artifact is its POM.
The auto-configuration class
Here is the whole thing. Read it once and then take the annotations apart.
@AutoConfiguration(before = ErrorMvcAutoConfiguration.class)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass(OncePerRequestFilter.class)
@ConditionalOnProperty(name = "requestid.enabled", havingValue = "true", matchIfMissing = true)
@Conditional(OnMissingTracingCondition.class)
@EnableConfigurationProperties(RequestIdProperties.class)
public final class RequestIdAutoConfiguration {
@Bean
@ConditionalOnMissingBean
RequestIdFilter requestIdFilter(RequestIdProperties properties) {
if (!StringUtils.hasText(properties.serviceName())) {
throw new RequestIdServiceNameMissingException();
}
return new RequestIdFilter(properties);
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(DefaultErrorAttributes.class)
static class ErrorAttributesConfiguration {
@Bean
@ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
RequestIdErrorAttributes requestIdErrorAttributes() {
return new RequestIdErrorAttributes();
}
}
}Six decisions, each earning its place:
| Annotation | Why it is there |
|---|---|
@AutoConfiguration(before = …) | marks the class and places it ahead of ErrorMvcAutoConfiguration in the order |
@ConditionalOnWebApplication(SERVLET) | a request filter means nothing outside a servlet application |
@ConditionalOnClass(OncePerRequestFilter.class) | spring-web is compileOnly, so it may genuinely be absent |
@ConditionalOnProperty(matchIfMissing = true) | on by default, with one property to switch it off |
@Conditional(OnMissingTracingCondition.class) | a question no built-in annotation can ask; written later in this article |
@EnableConfigurationProperties | binds and registers the settings object without the consumer doing anything |
@ConditionalOnMissingBean on the @Bean method is what makes the starter polite: declare a RequestIdFilter of your own and this one is never created.
The nested ErrorAttributesConfiguration is a pattern worth copying, and it is the one Boot uses throughout its own auto-configurations. A @Bean method cannot be guarded against the absence of its own return type: Spring resolves the method's return type to know what the method produces, so by the time the condition would be asked the type has already had to load. Moving the method into a nested @Configuration class and putting @ConditionalOnClass on the class solves it — if DefaultErrorAttributes is missing, the nested class is never loaded and the method is never seen.
⚠️
@ConditionalOnBooleanPropertyis the Boot 4 specialisation of the property condition, and reads better for an on/off flag.@ConditionalOnPropertywithhavingValue = "true"is used here because it is what most existing starters carry and what you will meet in other people's code.
What @AutoConfiguration adds to @Configuration
It is not a different kind of class. From the 4.1.1 source of the annotation, licence header and javadoc removed:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration(proxyBeanMethods = false)
@AutoConfigureBefore
@AutoConfigureAfter
public @interface AutoConfiguration {Three things come with it. It is a @Configuration, with proxyBeanMethods = false already chosen for you — auto-configurations do not call each other's @Bean methods, so the CGLIB subclass is pure cost. It carries @AutoConfigureBefore and @AutoConfigureAfter as meta-annotations, and aliases their attributes as before, beforeName, after and afterName, which is why the ordering fits on the same line. And the name is what Boot's own tooling looks for.
The beforeName and afterName variants take strings because these annotations are read from bytecode before the classes are loaded, so naming a class that is not on the classpath is safe there. The javadoc is explicit that the Class form is only safe when the annotation sits directly on the affected class, never when it is used as a meta-annotation.
Registering it in AutoConfiguration.imports
One file, one line, no version of it that involves spring.factories:
com.example.requestid.autoconfigure.RequestIdAutoConfigurationThe file name is the fully qualified name of the @AutoConfiguration annotation with .imports appended, under META-INF/spring/. Fully qualified class names, one per line, # for comments.
What happens when that line is missing
Nothing happens, which is the problem. Emptying the file, republishing and restarting the application produces an application that starts perfectly and does nothing:
grep -c RequestId run.log0Not one mention in a 438-line --debug log, 401 lines of which are the conditions report. The classes are on the classpath, the conditions are all satisfiable, and none of them is ever asked, because the class was never a candidate. The response confirms it:
HTTP/1.1 200
Content-Type: application/json
Content-Length: 19
Date: Fri, 18 Sep 2026 02:18:41 GMT
{"message":"hello"}No X-Request-Id. And the filter chain Boot logs at DEBUG has three entries instead of four:
Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105This is the single most common way a first starter fails, and the symptom is deceptive: nothing is broken, so there is no error to search for. The conditions report is the diagnostic. If your class does not appear under Positive matches, Negative matches or Exclusions, the problem is not a condition — the .imports file is missing, empty, in the wrong directory, or was not copied into the jar. With the line restored, the same run shows all four entries:
RequestIdAutoConfiguration matched:
- @ConditionalOnClass found required class 'org.springframework.web.filter.OncePerRequestFilter' (OnClassCondition)
- found 'session' scope (OnWebApplicationCondition)
- @ConditionalOnProperty (requestid.enabled=true) matched (OnPropertyCondition)
- MissingTracing did not find class io.micrometer.tracing.Tracer (OnMissingTracingCondition)
RequestIdAutoConfiguration#requestIdFilter matched:
- @ConditionalOnMissingBean (types: com.example.requestid.RequestIdFilter; SearchStrategy: all) did not find any beans (OnBeanCondition)
RequestIdAutoConfiguration.ErrorAttributesConfiguration matched:
- @ConditionalOnClass found required class 'org.springframework.boot.webmvc.error.DefaultErrorAttributes' (OnClassCondition)
RequestIdAutoConfiguration.ErrorAttributesConfiguration#requestIdErrorAttributes matched:
- @ConditionalOnMissingBean (types: org.springframework.boot.webmvc.error.ErrorAttributes; SearchStrategy: current) did not find any beans (OnBeanCondition)Ordering: before, after, and why @Order is a different thing
before = ErrorMvcAutoConfiguration.class was not decoration. Boot's ErrorMvcAutoConfiguration contributes the ErrorAttributes bean that decides what goes in an error response body, and it does so with a condition:
@Bean
@ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
DefaultErrorAttributes errorAttributes() {
return new DefaultErrorAttributes();
}Our starter wants the request id in that body, and it registers its own ErrorAttributes with the very same condition. Two auto-configurations, the same bean type, both backing off if the other got there first — so the order is the entire decision.

With before, ours runs first and Boot's backs off. The report:
ErrorMvcAutoConfiguration#errorAttributes:
Did not match:
- @ConditionalOnMissingBean (types: org.springframework.boot.webmvc.error.ErrorAttributes; SearchStrategy: current) found beans of type 'org.springframework.boot.webmvc.error.ErrorAttributes' requestIdErrorAttributes (OnBeanCondition)curl -s http://localhost:8201/api/nope{"timestamp":"2026-09-18T02:17:10.809Z","status":404,"error":"Not Found","path":"/api/nope","requestId":"orders-d83b5ec0-f8a4"}Change one word in the library and republish:
@AutoConfiguration(before = ErrorMvcAutoConfiguration.class)
@AutoConfiguration(after = ErrorMvcAutoConfiguration.class) Now Boot registers first, our condition finds a bean and declines, and the body loses the field:
RequestIdAutoConfiguration.ErrorAttributesConfiguration#requestIdErrorAttributes:
Did not match:
- @ConditionalOnMissingBean (types: org.springframework.boot.webmvc.error.ErrorAttributes; SearchStrategy: current) found beans of type 'org.springframework.boot.webmvc.error.ErrorAttributes' errorAttributes (OnBeanCondition){"timestamp":"2026-09-18T02:18:07.605Z","status":404,"error":"Not Found","path":"/api/nope"}One attribute, one bean, a visible difference in every error response the service will ever send.
Where the ordering is actually stored
Add spring-boot-autoconfigure-processor to annotationProcessor — it is already in the build file above — and the compiler writes the ordering and the cheap conditions into a flat index beside the classes:
com.example.requestid.autoconfigure.RequestIdAutoConfiguration=
com.example.requestid.autoconfigure.RequestIdAutoConfiguration$ErrorAttributesConfiguration=
com.example.requestid.autoconfigure.RequestIdAutoConfiguration$ErrorAttributesConfiguration.ConditionalOnClass=org.springframework.boot.webmvc.error.DefaultErrorAttributes
com.example.requestid.autoconfigure.RequestIdAutoConfiguration.AutoConfigureBefore=org.springframework.boot.webmvc.autoconfigure.error.ErrorMvcAutoConfiguration
com.example.requestid.autoconfigure.RequestIdAutoConfiguration.ConditionalOnClass=org.springframework.web.filter.OncePerRequestFilter
com.example.requestid.autoconfigure.RequestIdAutoConfiguration.ConditionalOnWebApplication=SERVLETThis is the file the filter described in Basics 10 reads, and generating it is a one-line opt-in that makes every consumer's startup slightly cheaper. It is optional; the ordering works without it, read from bytecode instead.
@AutoConfigureOrder is not @Order
Both exist, both take an int, and only one of them does anything here. Adding @AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 30) to the class produces a third key:
com.example.requestid.autoconfigure.RequestIdAutoConfiguration.AutoConfigureBefore=org.springframework.boot.webmvc.autoconfigure.error.ErrorMvcAutoConfiguration
com.example.requestid.autoconfigure.RequestIdAutoConfiguration.AutoConfigureOrder=-2147483618Replacing it with @Order(Ordered.HIGHEST_PRECEDENCE + 30) and rebuilding produces no such key at all — the metadata goes back to exactly what it was without either annotation. The sorter that orders auto-configurations reads AutoConfigureOrder, AutoConfigureBefore and AutoConfigureAfter, and never looks at @Order.
The two annotations answer different questions, and mixing them up is the classic starter bug:
| What it orders | Where it applies | |
|---|---|---|
@AutoConfiguration(before/after) | the order auto-configuration classes are processed | the auto-configuration class |
@AutoConfigureOrder | the same thing, as a number rather than a relation | the auto-configuration class |
@Order / Ordered | the position of a bean among other beans of its type | the bean — a filter, an interceptor, an advice |
The proof is in the filter chain. RequestIdFilter implements Ordered and returns HIGHEST_PRECEDENCE + 20, and Boot logs the resolved chain at DEBUG. Here it is from the before run:
Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, requestIdFilter urls=[/*] order=-2147483628, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105And here it is from the after run — the run where the auto-configuration order changed and the ErrorAttributes winner flipped:
Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, requestIdFilter urls=[/*] order=-2147483628, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105Byte for byte identical. Auto-configuration ordering decides who gets to register a bean; @Order decides where that bean sits at run time. Change one and the other does not move.
Typed settings and the metadata an IDE reads
The settings are a record, which is the Basics convention and also the right shape here: bound once, never mutated, and @DefaultValue puts every default in one place.
/**
* Settings for the request-id filter.
*
* @param enabled whether the filter is registered at all
* @param serviceName name this service is known by; it prefixes every generated id
* @param headerName header the incoming id is read from and the generated id is echoed on
* @param skipPaths request paths the filter leaves alone
* @param echo whether the id is written back on the response
*/
@Validated
@ConfigurationProperties("requestid")
public record RequestIdProperties(
@DefaultValue("true") boolean enabled,
String serviceName,
@DefaultValue("X-Request-Id")
@NotBlank
@Pattern(regexp = "[A-Za-z0-9-]+", message = "must contain only letters, digits and hyphens")
String headerName,
@DefaultValue("/actuator/**") List<String> skipPaths,
@DefaultValue("true") boolean echo) {
}@Validated plus the constraints means a typo fails the startup rather than the first request, with the offending key, the value and the origin:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar '--requestid.header-name=X Request Id'***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target com.example.requestid.RequestIdProperties failed:
Property: requestid.headerName
Value: "X Request Id"
Origin: "requestid.header-name" from property source "commandLineArgs"
Reason: must contain only letters, digits and hyphens
Action:
Update your application's configurationspring-boot-configuration-processor turns the same record into the file every IDE reads for completion. This is the complete generated artifact, from the built jar:
{
"groups": [
{
"name": "requestid",
"type": "com.example.requestid.RequestIdProperties",
"sourceType": "com.example.requestid.RequestIdProperties"
}
],
"properties": [
{
"name": "requestid.echo",
"type": "java.lang.Boolean",
"description": "whether the id is written back on the response",
"sourceType": "com.example.requestid.RequestIdProperties",
"defaultValue": true
},
{
"name": "requestid.header-name",
"type": "java.lang.String",
"description": "header the incoming id is read from and the generated id is echoed on",
"sourceType": "com.example.requestid.RequestIdProperties",
"defaultValue": "X-Request-Id"
},
{
"name": "requestid.service-name",
"type": "java.lang.String",
"description": "name this service is known by; it prefixes every generated id",
"sourceType": "com.example.requestid.RequestIdProperties"
},
{
"name": "requestid.skip-paths",
"type": "java.util.List<java.lang.String>",
"description": "request paths the filter leaves alone",
"sourceType": "com.example.requestid.RequestIdProperties",
"defaultValue": "\/actuator\/**"
}
],
"hints": [],
"ignored": {
"properties": []
}
}Three details worth noticing. The keys are kebab-cased, so the consumer's IDE offers requestid.header-name rather than the Java spelling. Each description is the @param javadoc of the record component — writing that javadoc is what makes the consumer's tooltip useful, and leaving it out costs the entry its description. And defaultValue comes from @DefaultValue, so the IDE can show the default without the consumer opening your source; requestid.service-name has no defaultValue for the honest reason that it has no default.
In the consuming application, the settings are ordinary configuration:
requestid.service-name=orders
requestid.header-name=X-Request-Id
requestid.skip-paths=/actuator/**requestid:
service-name: orders
header-name: X-Request-Id
skip-paths:
- /actuator/**A condition of your own
The 21 @ConditionalOn* annotations cover almost everything, but not this: back off entirely if the application already has Micrometer Tracing switched on, because a trace id is already a request id and two competing ids in the MDC is worse than one. That question is a classpath check and a property check combined with a negation, and no single built-in annotation expresses it.
Implement Condition and you get a boolean. Extend SpringBootCondition and you get a ConditionOutcome, which carries a message — and the message is what shows up in the report:
public class OnMissingTracingCondition extends SpringBootCondition {
private static final String TRACER = "io.micrometer.tracing.Tracer";
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
ConditionMessage.Builder message = ConditionMessage.forCondition("MissingTracing");
if (!ClassUtils.isPresent(TRACER, context.getClassLoader())) {
return ConditionOutcome.match(message.didNotFind("class").items(TRACER));
}
if ("false".equalsIgnoreCase(context.getEnvironment().getProperty("management.tracing.enabled"))) {
return ConditionOutcome.match(message.because(TRACER + " is present but tracing is disabled"));
}
return ConditionOutcome.noMatch(message.found("class").items(TRACER));
}
}ConditionMessage.forCondition("MissingTracing") sets the prefix every outcome starts with, and didNotFind(...).items(...), found(...).items(...) and because(...) are the builders that produce sentences in the same shape as Boot's own. Three report lines, one per branch, each from its own run.
Nothing on the classpath, which is the normal case:
RequestIdAutoConfiguration matched:
...
- MissingTracing did not find class io.micrometer.tracing.Tracer (OnMissingTracingCondition)Add io.micrometer:micrometer-tracing to the application and the starter steps aside, with both halves of the decision printed:
RequestIdAutoConfiguration:
Did not match:
- MissingTracing found class io.micrometer.tracing.Tracer (OnMissingTracingCondition)
Matched:
- @ConditionalOnClass found required class 'org.springframework.web.filter.OncePerRequestFilter' (OnClassCondition)
- found 'session' scope (OnWebApplicationCondition)
- @ConditionalOnProperty (requestid.enabled=true) matched (OnPropertyCondition)And with tracing present but turned off, the third branch:
RequestIdAutoConfiguration matched:
- @ConditionalOnClass found required class 'org.springframework.web.filter.OncePerRequestFilter' (OnClassCondition)
- found 'session' scope (OnWebApplicationCondition)
- @ConditionalOnProperty (requestid.enabled=true) matched (OnPropertyCondition)
- MissingTracing io.micrometer.tracing.Tracer is present but tracing is disabled (OnMissingTracingCondition)Three states, three sentences, all of them in the report a consumer can read without opening your source. A plain Condition would have decided the same way and printed nothing — which is why SpringBootCondition is the base class to extend in a library.
Testing an auto-configuration with ApplicationContextRunner
@SpringBootTest is the wrong tool here. It starts one context with one classpath and one set of properties, and the thing under test is precisely how the class behaves across different contexts, classpaths and properties. ApplicationContextRunner — WebApplicationContextRunner for a servlet condition — builds a throwaway context per scenario in milliseconds.
class RequestIdAutoConfigurationTests {
private final WebApplicationContextRunner runner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(RequestIdAutoConfiguration.class))
.withPropertyValues("requestid.service-name=orders");
@Test
void registersTheFilterByDefault() {
runner.run((context) -> {
assertThat(context).hasSingleBean(RequestIdFilter.class);
assertThat(context.getBean(RequestIdProperties.class).headerName()).isEqualTo("X-Request-Id");
});
}
@Test
void backsOffWhenDisabled() {
runner.withPropertyValues("requestid.enabled=false")
.run((context) -> assertThat(context).doesNotHaveBean(RequestIdFilter.class));
}
@Test
void backsOffWhenTheApplicationDeclaresItsOwnFilter() {
runner.withUserConfiguration(OwnFilterConfiguration.class).run((context) -> {
assertThat(context).hasSingleBean(RequestIdFilter.class);
assertThat(context.getBean(RequestIdFilter.class).getOrder()).isEqualTo(5);
});
}
@Test
void backsOffWhenSpringWebIsMissing() {
runner.withClassLoader(new FilteredClassLoader(OncePerRequestFilter.class))
.run((context) -> assertThat(context).doesNotHaveBean("requestIdFilter"));
}
@Test
void failsWhenNoServiceNameIsConfigured() {
new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(RequestIdAutoConfiguration.class))
.run((context) -> {
assertThat(context).hasFailed();
assertThat(context).getFailure()
.rootCause()
.isInstanceOf(RequestIdServiceNameMissingException.class)
.hasMessage("requestid.service-name is not set");
});
}
@Configuration(proxyBeanMethods = false)
static class OwnFilterConfiguration {
@Bean
RequestIdFilter requestIdFilter() {
RequestIdFilter filter = new RequestIdFilter(
new RequestIdProperties(true, "orders", "X-Correlation-Id", List.of(), true));
filter.setOrder(5);
return filter;
}
}
}Five methods that cover the five things a starter has to get right, and four API details that are the whole reason this tool exists:
withConfiguration(AutoConfigurations.of(...))registers the class as an auto-configuration, so it is processed last and sorted the way it would be in a real application.withUserConfigurationregisters it as an ordinary@Configuration, which would make every@ConditionalOnMissingBeanin it answer the wrong question.withUserConfiguration(...)is then exactly right for the application's own beans, because that is what they are.withClassLoader(new FilteredClassLoader(OncePerRequestFilter.class))hides a class from the context without touching the build. The assertion uses the bean name, since the type is unloadable in that context.assertThat(context).hasFailed()is how a failure is asserted: the runner catches the startup exception instead of throwing it, so the test can inspect it.
./gradlew :request-id-spring-boot-autoconfigure:test --rerun-tasksRequestIdAutoConfigurationTests > backsOffWhenDisabled() PASSED
RequestIdAutoConfigurationTests > registersTheFilterByDefault() PASSED
RequestIdAutoConfigurationTests > backsOffWhenSpringWebIsMissing() PASSED
RequestIdAutoConfigurationTests > failsWhenNoServiceNameIsConfigured() PASSED
RequestIdAutoConfigurationTests > backsOffWhenTheApplicationDeclaresItsOwnFilter() PASSED
BUILD SUCCESSFUL in 1sThe same four outcomes, seen from the consumer's side rather than the test's:

Using the starter from an application
publishToMavenLocal puts both artifacts in ~/.m2/repository:
./gradlew publishToMavenLocalA composite build — includeBuild('../request-id') in the application's settings.gradle — would also work and skips the publish step entirely, which is the better inner loop when you are changing the library every minute. mavenLocal() is used here because it exercises the artifact a consumer actually resolves: the jar, the POM and the dependency graph the POM declares. Gradle re-reads mavenLocal() on every build: republishing the same 0.0.1 version and running bootJar again picked up every library change in this article without --refresh-dependencies.
The application is a stock Initializr project with two lines added:
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'com.example:request-id-spring-boot-starter:0.0.1'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}What that one line brings, with the version-management rows removed:
./gradlew dependencies --configuration runtimeClasspath\--- com.example:request-id-spring-boot-starter:0.0.1
+--- org.springframework.boot:spring-boot-dependencies:4.1.1
+--- com.example:request-id-spring-boot-autoconfigure:0.0.1
| +--- org.slf4j:slf4j-api -> 2.0.18
| \--- org.springframework.boot:spring-boot-autoconfigure -> 4.1.1
+--- org.springframework.boot:spring-boot-starter -> 4.1.1
\--- org.springframework.boot:spring-boot-starter-validation -> 4.1.1
+--- org.springframework.boot:spring-boot-starter:4.1.1
\--- org.springframework.boot:spring-boot-validation:4.1.1
+--- org.apache.tomcat.embed:tomcat-embed-el:11.0.24
\--- org.hibernate.validator:hibernate-validator:9.1.3.Final
+--- jakarta.validation:jakarta.validation-api:3.1.1
+--- org.jboss.logging:jboss-logging:3.6.3.Final
\--- com.fasterxml:classmate:1.7.3No spring-boot-starter-webmvc anywhere in that subtree. The application chose its own web stack; the starter did not choose one for it.
Configuration is three lines, one of which also makes the id visible in the logs:
server.port=8201
logging.pattern.level=%5p [%X{requestId}]
requestid.service-name=ordersAnd the whole thing, end to end. A request with no id gets one:
curl -s -D - http://localhost:8201/api/helloHTTP/1.1 200
X-Request-Id: orders-6f4e96da-ede9
Content-Type: application/json
Content-Length: 19
Date: Fri, 18 Sep 2026 02:17:10 GMT
{"message":"hello"}A request that already carries one keeps it, which is what makes the id survive a hop between services:
curl -s -D - -H 'X-Request-Id: from-gateway-42' http://localhost:8201/api/helloHTTP/1.1 200
X-Request-Id: from-gateway-42
Content-Type: application/json
Content-Length: 19
Date: Fri, 18 Sep 2026 02:17:10 GMT
{"message":"hello"}Both ids reached the MDC, so the application's own log lines carry them without the controller knowing anything about it:
INFO [orders-6f4e96da-ede9] --- [demo] [nio-8201-exec-2] com.example.demo.HelloController : handling /api/hello
INFO [from-gateway-42] --- [demo] [nio-8201-exec-4] com.example.demo.HelloController : handling /api/helloAn error response carries it in the body as well as the header, which is the ErrorAttributes bean the ordering won:
HTTP/1.1 404
X-Request-Id: orders-d83b5ec0-f8a4
Content-Type: application/json
{"timestamp":"2026-09-18T02:17:10.809Z","status":404,"error":"Not Found","path":"/api/nope","requestId":"orders-d83b5ec0-f8a4"}Overriding a property
Two properties on the command line, no rebuild:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --requestid.header-name=X-Correlation-Id '--requestid.skip-paths=/actuator/**,/api/hello'/api/hello is now in the skip list and gets no header at all:
HTTP/1.1 200
Content-Type: application/json
Content-Length: 19while everything else uses the new header name:
HTTP/1.1 404
X-Correlation-Id: orders-9872f12e-09c7
Content-Type: application/json
{"timestamp":"2026-09-18T02:20:23.973Z","status":404,"error":"Not Found","path":"/api/nope","requestId":"orders-9872f12e-09c7"}Overriding the bean
When a property is not enough, the application declares the bean and the starter disappears:
@Configuration(proxyBeanMethods = false)
public class RequestIdConfig {
@Bean
RequestIdFilter requestIdFilter() {
RequestIdFilter filter = new RequestIdFilter(
new RequestIdProperties(true, "checkout", "X-Trace-Id", List.of(), true));
filter.setOrder(Ordered.LOWEST_PRECEDENCE);
return filter;
}
}HTTP/1.1 200
X-Trace-Id: checkout-373d8c14-ec6e
Content-Type: application/json
Content-Length: 19The report records the back-off, naming the bean that caused it:
RequestIdAutoConfiguration#requestIdFilter:
Did not match:
- @ConditionalOnMissingBean (types: com.example.requestid.RequestIdFilter; SearchStrategy: all) found beans of type 'com.example.requestid.RequestIdFilter' requestIdFilter (OnBeanCondition)And because the application's bean chose a different order, it also moved in the chain — from second to last:
Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105, requestIdFilter urls=[/*] order=2147483647Telling the consumer what went wrong
requestid.service-name has no default, because an id prefixed with a guess is worse than no id. A bean method that throws produces a correct but unhelpful stack trace, so the starter ships a FailureAnalyzer, exactly as Boot does for a missing datasource URL. Two small classes:
public class RequestIdServiceNameMissingException extends RuntimeException {
public RequestIdServiceNameMissingException() {
super("requestid.service-name is not set");
}
}class RequestIdFailureAnalyzer extends AbstractFailureAnalyzer<RequestIdServiceNameMissingException> {
@Override
protected FailureAnalysis analyze(Throwable rootFailure, RequestIdServiceNameMissingException cause) {
return new FailureAnalysis(
"The request-id starter is on the classpath, but 'requestid.service-name' is not set. "
+ "Generated ids are prefixed with it, so the starter will not guess one.",
"Set 'requestid.service-name' in application.properties to the name this service is "
+ "known by, or set 'requestid.enabled=false' to switch the starter off.",
cause);
}
}AbstractFailureAnalyzer<T> walks the cause chain for you and hands you the T it found, wherever it was buried. The two constructor arguments become the two sections of the message, and the second one has to be an instruction, not a restatement.
This is the one thing in a starter still registered through spring.factories, which Boot 4 kept for everything that is not the candidate list:
org.springframework.boot.diagnostics.FailureAnalyzer=\
com.example.requestid.autoconfigure.RequestIdFailureAnalyzerStart the application without the property and the output is answerable:
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
***************************
APPLICATION FAILED TO START
***************************
Description:
The request-id starter is on the classpath, but 'requestid.service-name' is not set. Generated ids are prefixed with it, so the starter will not guess one.
Action:
Set 'requestid.service-name' in application.properties to the name this service is known by, or set 'requestid.enabled=false' to switch the starter off.A required property is a strong choice, and most starters should default instead. When you do make one required, a FailureAnalyzer is the difference between a consumer reading your source and a consumer fixing it in ten seconds.
What not to do in a starter
Do not component-scan. @ComponentScan on an auto-configuration replaces the entire override story with nothing: scanned @Component classes are registered unconditionally, so @ConditionalOnMissingBean has nothing to attach to and the consumer cannot replace your bean by declaring their own. Every override in this article — the filter, the ErrorAttributes — works because the beans come from explicit @Bean methods with conditions on them. Worse, a scan is rooted at a package the consumer does not control, so a base package that overlaps theirs quietly registers their classes too.
Do not compile against the consumer's code. The autoconfigure module compiles against Boot, SLF4J and four compileOnly entries, and nothing that belongs to an application. A starter that needs a type from the application inverts the dependency and can only ever be used by one application.
Do not force a web stack. The starter here is unmistakably about HTTP, and it still does not depend on spring-boot-starter-webmvc — the dependency tree above shows it is not there. spring-web and spring-boot-webmvc are compileOnly, and @ConditionalOnClass(OncePerRequestFilter.class) makes their absence a clean back-off rather than a crash, which backsOffWhenSpringWebIsMissing proves without touching the build. Let the application pick its own web framework, servlet container and JSON library.
Do not apply the Spring Boot Gradle plugin to a library module. It exists to build an executable application. Adding it to the starter module fails the build outright:
> Task :request-id-spring-boot-starter:bootJar FAILED
> Error while evaluating property 'mainClass' of task ':request-id-spring-boot-starter:bootJar'.
> Failed to calculate the value of task ':request-id-spring-boot-starter:bootJar' property 'mainClass'.
> Main class name has not been configured and it could not be resolved from classpathUse java-library and import spring-boot-dependencies as a platform, as the root build above does.
FAQ
Where does a custom auto-configuration have to be registered in Spring Boot 4?
In META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports inside your jar, one fully qualified class name per line. The META-INF/spring.factories key named EnableAutoConfiguration was deprecated in Boot 2.7 and removed in 3.0; it does nothing on 4.1.1. spring.factories is still read for other extension points, which is why the FailureAnalyzer in this article is registered there.
Why is my starter's bean missing with no error at all?
Almost always because the class is not a candidate. Run with --debug and search the report for its name: if it appears nowhere at all — not under Positive matches, Negative matches or Exclusions — the .imports file is missing, empty, or did not make it into the jar. Confirm with unzip -l on the published artifact. If it does appear under Negative matches, the Did not match: line names the condition that rejected it.
What is the difference between @AutoConfigureOrder and @Order on an auto-configuration?
@AutoConfigureOrder orders auto-configuration classes against each other, and the annotation processor writes it into spring-autoconfigure-metadata.properties as an AutoConfigureOrder entry. @Order writes nothing there and the sorter never reads it — it orders beans of the same type, such as filters in a chain. The two axes are independent: in this article, flipping before to after changed which ErrorAttributes bean survived and left the filter chain byte-for-byte identical.
Should the starter module contain any code?
No. It contains a build.gradle and nothing else; its published jar here is 25 bytes of manifest. All the code, the conditions and the metadata live in the -spring-boot-autoconfigure module, so a consumer who wants the classes without the transitive dependencies can depend on that module directly.
How do I test that my auto-configuration backs off when a class is missing?
ApplicationContextRunner.withClassLoader(new FilteredClassLoader(TheClass.class)). It hides the class from that context only, with no change to the build, and the whole test runs in milliseconds. Assert on the bean name rather than the type, because the type may be unloadable in the filtered context.
Is spring-boot-configuration-processor worth adding to a starter?
Yes — it is one annotationProcessor line and it is what gives every consumer completion, types and defaults for your keys. Write javadoc on the @ConfigurationProperties record components while you are there: each @param becomes the description field in the generated JSON, and without it the entry ships with no explanation.
Conclusion
A starter is three files' worth of ideas and a lot of care about defaults. @AutoConfiguration is a @Configuration with proxyBeanMethods = false and ordering attributes; one line in AutoConfiguration.imports is the difference between a working starter and a jar that silently does nothing; @ConditionalOnMissingBean is what makes every bean replaceable; before and after decide who gets to register a contested bean, which @Order does not touch; @ConfigurationProperties with the configuration processor gives the consumer's IDE everything it needs; a SpringBootCondition puts your own reasoning into the report next to Boot's; ApplicationContextRunner tests all of it in milliseconds; and a FailureAnalyzer turns your one required property from a stack trace into an instruction.
The mechanisms underneath were all container features rather than Boot features — a condition is Spring Framework, a @Bean method is Spring Framework, and auto-configuration is a thin convention over both. The next article stays at that level and goes one step deeper into the container itself: BeanPostProcessor and BeanFactoryPostProcessor, the Aware interfaces, and ApplicationRunner and CommandLineRunner — the extension points you reach for when a bean has to be inspected, rewritten or run at exactly the right moment.