Spring Boot Basics khép lại bằng một Order Management API chạy được. Course này bắt đầu ngay từ đó: những phần của Spring Boot mà bạn phải đụng tới khi application đã chạy và câu hỏi chuyển thành nó thực sự hoạt động thế nào, mở rộng nó ra sao, và làm sao dùng lại cho mọi service của team. Đối tượng là người đã học xong course Basics hoặc đang viết Spring Boot chuyên nghiệp — bạn được giả định là đã nắm bean, binding config, REST, data access, security và testing, và muốn hiểu tầng nằm bên dưới chúng.
Chủ đề đầu tiên là auto-configuration, nhìn từ phía người viết ra nó. Bài 10 của Basics đã mở hộp: Boot dựng danh sách candidate thế nào, họ @ConditionalOn* quyết định gì, back-off hoạt động ra sao, và đọc report --debug thế nào. Bài này không nhắc lại những thứ đó. Bài này là về việc tự viết một cái — một starter thật, có module library, module starter, các condition, settings có kiểu, thứ tự so với chính các class của Boot, test, artifact đã publish và một application dùng nó.
![]()
Các ví dụ dùng Spring Boot 4.1.1 và Java 21. Auto-configuration là chủ đề đầy lời khuyên từ thời spring.factories, thứ đã không còn dùng được từ Boot 3, nên bài này cho thấy Boot 4 thực sự làm gì.
Chúng ta sẽ xây gì, và tại sao chọn ví dụ này
Starter này gắn cho mỗi HTTP request một id. Nó đọc header đến nếu caller đã gửi, sinh một id có tiền tố nếu chưa có, đưa id vào MDC của SLF4J để mọi dòng log đều mang nó, trả id lại trên response, và thêm id vào body của mọi error response. Ba thứ khác nhau giữa các service — tên header, các path bỏ qua, và tiền tố nhận diện service — đều là configuration.
Đó đúng là hình dạng của một thứ đáng đóng gói: hành vi giống hệt nhau ở mọi service, vài nút vặn, và không lý do gì để ai đó viết lại lần thứ hai. Nó cũng đủ nhỏ để gói trọn trong một bài mà vẫn chạm tới mọi cơ chế một starter thật cần — condition, settings có kiểu, thứ tự so với một auto-configuration của Boot, một condition tự viết, và một thông báo lỗi tử tế.
Ba Gradle project
Library và starter là hai module của cùng một Gradle build; application là một project Initializr riêng, lấy artifact đã publish về.
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
Quy tắc đặt tên không phải thứ bạn được bẻ cong. spring-boot-starter-* được dành riêng cho các starter do chính team Spring Boot phát hành; mọi thứ khác phải có dạng <name>-spring-boot-starter, với module library tên là <name>-spring-boot-autoconfigure. Lý do thực dụng chứ không phải pháp lý: người đọc danh sách dependency dựa vào tiền tố để biết ngay artifact nào do team Boot bảo trì và artifact nào không.
Việc tách làm hai module cũng không phải cho đẹp. Module autoconfigure giữ toàn bộ code và toàn bộ condition. Module starter không có dòng code nào — nó tồn tại để người dùng chỉ viết một dòng dependency mà có đủ library cùng mọi thứ library cần lúc chạy. Tách ra như vậy còn cho phép ai chỉ muốn code mà không muốn ý kiến áp đặt thì phụ thuộc thẳng vào module autoconfigure.
rootProject.name = 'request-id'
include 'request-id-spring-boot-autoconfigure'
include 'request-id-spring-boot-starter'Build ở root cấu hình cả hai module. Để ý thứ nó không làm: nó không bao giờ apply plugin org.springframework.boot. Việc của plugin đó là tạo ra một application chạy được, mà library thì không phải thế.
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') là cách một library nhận được các version mà Boot quản lý mà không cần plugin của Boot: mọi toạ độ Boot và Spring bên dưới nhờ vậy viết được mà không kèm version. Dòng được tô sáng có mặt ở đó vì lần đầu tôi quên nó, và build hỏng với một thông báo đáng nhớ mặt:
> Could not resolve all files for configuration ':request-id-spring-boot-autoconfigure:annotationProcessor'.
> Could not find org.springframework.boot:spring-boot-configuration-processor:.Dấu hiệu nằm ở phần version rỗng sau dấu hai chấm cuối. Một platform chỉ áp dụng cho configuration nó được khai báo và những configuration kế thừa configuration đó; annotationProcessor không kế thừa cái nào, nên nó cần dòng riêng.
Module autoconfigure là nơi có những quyết định dependency đáng bàn:
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'
}Mọi thứ liên quan tới web đều là compileOnly. Code cần OncePerRequestFilter và DefaultErrorAttributes để compile, nhưng một người dùng không phải servlet web application thì không được phép bị kéo theo Tomcat chỉ vì đã dùng một starter request-id. @ConditionalOnClass khiến việc thiếu chúng trở nên vô hại lúc chạy, và bộ test chứng minh điều đó. Servlet API là compileOnly vì lý do quen thuộc: container cung cấp nó.
Module starter chính là lý do quy tắc đặt tên tồn tại, và nó chỉ có bốn dòng:
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 là lựa chọn có chủ ý chứ không phải sơ suất: starter hứa rằng cấu hình sai sẽ chết ngay lúc khởi động thay vì ở request đầu tiên, và lời hứa đó cần một implementation JSR-303 trên classpath. Đây là dependency duy nhất starter này thêm vào thay cho người dùng, và bài viết nói rõ điều đó vì một starter lặng lẽ kéo theo cả web stack chính là thứ ai cũng than phiền.
Jar đã publish chứng minh module này rỗng:
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 filesHai mươi lăm byte manifest. Các starter của chính Boot cũng vậy: artifact chính là file POM của nó.
Class auto-configuration
Đây là toàn bộ. Đọc một lượt rồi mổ xẻ từng annotation.
@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();
}
}
}Sáu quyết định, cái nào cũng có lý do:
| Annotation | Vì sao có mặt |
|---|---|
@AutoConfiguration(before = …) | đánh dấu class và đặt nó đứng trước ErrorMvcAutoConfiguration trong thứ tự |
@ConditionalOnWebApplication(SERVLET) | một filter request chẳng có nghĩa gì ngoài servlet application |
@ConditionalOnClass(OncePerRequestFilter.class) | spring-web là compileOnly, nên nó có thể thật sự vắng mặt |
@ConditionalOnProperty(matchIfMissing = true) | mặc định bật, với một property để tắt |
@Conditional(OnMissingTracingCondition.class) | một câu hỏi không annotation có sẵn nào hỏi được; viết ở phần sau bài |
@EnableConfigurationProperties | bind và đăng ký object settings mà người dùng không phải làm gì |
@ConditionalOnMissingBean trên @Bean method là thứ khiến starter biết điều: khai báo một RequestIdFilter của riêng bạn và bean này sẽ không bao giờ được tạo.
Class lồng ErrorAttributesConfiguration là một pattern đáng chép lại, và cũng là pattern Boot dùng xuyên suốt các auto-configuration của chính nó. Một @Bean method không thể tự bảo vệ trước sự vắng mặt của chính kiểu nó trả về: Spring phải phân giải kiểu trả về để biết method tạo ra cái gì, nên tới lúc condition được hỏi thì kiểu đó đã buộc phải load rồi. Chuyển method vào một class @Configuration lồng bên trong rồi đặt @ConditionalOnClass lên class là xong — nếu thiếu DefaultErrorAttributes, class lồng không bao giờ được load và method không bao giờ bị nhìn thấy.
⚠️
@ConditionalOnBooleanPropertylà bản chuyên biệt của Boot 4 cho property condition, và đọc gọn hơn với một cờ bật/tắt. Ở đây dùng@ConditionalOnPropertyvớihavingValue = "true"vì đó là thứ phần lớn starter hiện có đang mang và là thứ bạn sẽ gặp trong code của người khác.
@AutoConfiguration thêm gì so với @Configuration
Nó không phải một loại class khác. Trích từ source 4.1.1 của chính annotation đó, đã bỏ phần licence và javadoc:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration(proxyBeanMethods = false)
@AutoConfigureBefore
@AutoConfigureAfter
public @interface AutoConfiguration {Nó mang theo ba thứ. Nó là một @Configuration, với proxyBeanMethods = false đã chọn sẵn cho bạn — các auto-configuration không gọi @Bean method của nhau, nên subclass CGLIB chỉ là chi phí thừa. Nó mang @AutoConfigureBefore và @AutoConfigureAfter dưới dạng meta-annotation, và đặt bí danh cho các thuộc tính của chúng thành before, beforeName, after, afterName, nhờ vậy phần thứ tự viết gọn trên cùng một dòng. Và cái tên chính là thứ bộ công cụ của Boot tìm kiếm.
Hai biến thể beforeName và afterName nhận chuỗi vì những annotation này được đọc từ bytecode trước khi các class được load, nên nêu tên một class không có trên classpath là an toàn. Javadoc nói rõ rằng dạng Class chỉ an toàn khi annotation nằm trực tiếp trên class bị ảnh hưởng, không bao giờ an toàn khi nó được dùng làm meta-annotation.
Đăng ký trong AutoConfiguration.imports
Một file, một dòng, và không có phiên bản nào của nó dính tới spring.factories:
com.example.requestid.autoconfigure.RequestIdAutoConfigurationTên file là tên đầy đủ của annotation @AutoConfiguration cộng thêm .imports, nằm dưới META-INF/spring/. Mỗi dòng một tên class đầy đủ, # để ghi chú.
Chuyện gì xảy ra khi thiếu dòng đó
Không có gì xảy ra cả, và đó chính là vấn đề. Xoá rỗng file, publish lại rồi khởi động lại application cho ra một application chạy hoàn hảo và không làm gì hết:
grep -c RequestId run.log0Không một lần nhắc tới trong 438 dòng log --debug, trong đó 401 dòng là conditions report. Các class vẫn nằm trên classpath, mọi condition đều thoả được, và không cái nào bị hỏi tới, vì class chưa bao giờ là candidate. Response xác nhận điều đó:
HTTP/1.1 200
Content-Type: application/json
Content-Length: 19
Date: Fri, 18 Sep 2026 02:18:41 GMT
{"message":"hello"}Không có X-Request-Id. Và chuỗi filter mà Boot log ở mức DEBUG có ba mục thay vì bốn:
Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105Đây là kiểu hỏng phổ biến nhất của một starter đầu tay, và triệu chứng rất đánh lừa: không có gì vỡ cả, nên chẳng có lỗi nào để tìm. Conditions report là công cụ chẩn đoán. Nếu class của bạn không xuất hiện ở Positive matches, Negative matches hay Exclusions, thì vấn đề không nằm ở condition — file .imports đang thiếu, rỗng, sai thư mục, hoặc không được đóng vào jar. Khi đã khôi phục dòng đó, cùng lần chạy ấy hiện đủ bốn mục:
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)Thứ tự: before, after, và vì sao @Order là chuyện khác
before = ErrorMvcAutoConfiguration.class không phải để trang trí. ErrorMvcAutoConfiguration của Boot đóng góp bean ErrorAttributes quyết định những gì đi vào body của error response, và nó làm điều đó kèm một condition:
@Bean
@ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
DefaultErrorAttributes errorAttributes() {
return new DefaultErrorAttributes();
}Starter của chúng ta muốn request id nằm trong body đó, nên nó đăng ký ErrorAttributes riêng với đúng condition ấy. Hai auto-configuration, cùng một kiểu bean, cả hai đều lùi lại nếu bên kia tới trước — nên thứ tự chính là toàn bộ quyết định.

Với before, bean của chúng ta chạy trước và bean của Boot lùi lại. 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"}Đổi một chữ trong library rồi publish lại:
@AutoConfiguration(before = ErrorMvcAutoConfiguration.class)
@AutoConfiguration(after = ErrorMvcAutoConfiguration.class) Giờ Boot đăng ký trước, condition của chúng ta thấy đã có bean nên từ chối, và body mất đi 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"}Một thuộc tính, một bean, và khác biệt nhìn thấy được trong mọi error response service sẽ gửi đi.
Thứ tự thực ra được lưu ở đâu
Thêm spring-boot-autoconfigure-processor vào annotationProcessor — nó đã có sẵn trong file build ở trên — và compiler sẽ ghi thứ tự cùng các condition rẻ tiền vào một bảng index phẳng nằm cạnh các class:
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=SERVLETĐây chính là file mà bộ filter được mô tả trong Basics bài 10 đọc, và sinh ra nó chỉ tốn một dòng khai báo nhưng làm mọi lần khởi động của người dùng rẻ đi một chút. Nó không bắt buộc; thứ tự vẫn chạy khi không có nó, chỉ là đọc từ bytecode.
@AutoConfigureOrder không phải @Order
Cả hai đều tồn tại, cả hai đều nhận một int, và chỉ một trong hai có tác dụng ở đây. Thêm @AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 30) lên class sinh ra khoá thứ ba:
com.example.requestid.autoconfigure.RequestIdAutoConfiguration.AutoConfigureBefore=org.springframework.boot.webmvc.autoconfigure.error.ErrorMvcAutoConfiguration
com.example.requestid.autoconfigure.RequestIdAutoConfiguration.AutoConfigureOrder=-2147483618Thay nó bằng @Order(Ordered.HIGHEST_PRECEDENCE + 30) rồi build lại thì không sinh ra khoá nào — metadata quay về đúng như khi không có annotation nào cả. Bộ sắp xếp các auto-configuration đọc AutoConfigureOrder, AutoConfigureBefore và AutoConfigureAfter, và không bao giờ nhìn tới @Order.
Hai annotation trả lời hai câu hỏi khác nhau, và nhầm chúng là lỗi kinh điển khi viết starter:
| Nó sắp thứ tự cái gì | Nó đặt ở đâu | |
|---|---|---|
@AutoConfiguration(before/after) | thứ tự xử lý các class auto-configuration | trên class auto-configuration |
@AutoConfigureOrder | cũng thứ đó, nhưng bằng con số thay vì quan hệ | trên class auto-configuration |
@Order / Ordered | vị trí của một bean giữa các bean cùng kiểu | trên bean — một filter, một interceptor, một advice |
Bằng chứng nằm ở chuỗi filter. RequestIdFilter implements Ordered và trả về HIGHEST_PRECEDENCE + 20, còn Boot log chuỗi đã phân giải ở mức DEBUG. Đây là dòng đó ở lần chạy before:
Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, requestIdFilter urls=[/*] order=-2147483628, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105Và đây là dòng đó ở lần chạy after — lần mà thứ tự auto-configuration đã đổi và bên thắng ErrorAttributes đã đảo:
Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, requestIdFilter urls=[/*] order=-2147483628, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105Giống nhau từng byte. Thứ tự auto-configuration quyết định ai được quyền đăng ký bean; @Order quyết định bean đó nằm ở đâu lúc chạy. Đổi cái này thì cái kia không nhúc nhích.
Settings có kiểu và metadata mà IDE đọc
Settings là một record, đúng quy ước của Basics và cũng đúng hình dạng cần ở đây: bind một lần, không bao giờ đổi, và @DefaultValue gom mọi giá trị mặc định về một chỗ.
/**
* 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 cộng với các constraint khiến một lỗi gõ nhầm làm chết lúc khởi động thay vì ở request đầu tiên, kèm đúng khoá sai, giá trị và nguồn gốc của nó:
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 biến chính record đó thành file mà mọi IDE đọc để gợi ý. Đây là toàn bộ artifact được sinh ra, lấy từ jar đã build:
{
"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": []
}
}Ba chi tiết đáng để ý. Các khoá được chuyển sang kiểu kebab-case, nên IDE của người dùng gợi ý requestid.header-name chứ không phải cách viết trong Java. Mỗi description chính là javadoc @param của thành phần record — viết javadoc đó là thứ làm tooltip của người dùng có ích, bỏ qua nó thì entry mất phần mô tả. Và defaultValue lấy từ @DefaultValue, nên IDE hiện được giá trị mặc định mà người dùng không phải mở source của bạn; requestid.service-name không có defaultValue vì lý do thành thật: nó không có mặc định.
Trong application sử dụng, settings chỉ là configuration bình thường:
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/**Một condition của riêng bạn
21 annotation @ConditionalOn* bao phủ gần như mọi thứ, trừ chuyện này: lùi lại hoàn toàn nếu application đã bật Micrometer Tracing, vì trace id vốn đã là request id và hai id tranh nhau trong MDC còn tệ hơn một id. Câu hỏi đó là một phép kiểm tra classpath và một phép kiểm tra property ghép lại kèm phủ định, và không annotation có sẵn đơn lẻ nào diễn đạt được.
Implement Condition thì bạn nhận về một boolean. Kế thừa SpringBootCondition thì bạn nhận về một ConditionOutcome, thứ mang theo một message — và message đó chính là cái xuất hiện trong 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") đặt tiền tố mà mọi outcome bắt đầu bằng, còn didNotFind(...).items(...), found(...).items(...) và because(...) là các builder tạo ra câu có cùng hình dạng với câu của chính Boot. Ba dòng report, mỗi nhánh một dòng, mỗi dòng từ một lần chạy riêng.
Không có gì trên classpath, tức trường hợp bình thường:
RequestIdAutoConfiguration matched:
...
- MissingTracing did not find class io.micrometer.tracing.Tracer (OnMissingTracingCondition)Thêm io.micrometer:micrometer-tracing vào application thì starter tự tránh ra, với cả hai nửa của quyết định đều được in ra:
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)Còn khi tracing có mặt nhưng bị tắt, ta có nhánh thứ ba:
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)Ba trạng thái, ba câu, và tất cả đều nằm trong report mà người dùng đọc được mà không cần mở source của bạn. Một Condition thuần sẽ quyết định y hệt nhưng không in gì cả — đó là lý do SpringBootCondition mới là class nên kế thừa khi viết library.
Test một auto-configuration bằng ApplicationContextRunner
@SpringBootTest là công cụ sai ở đây. Nó dựng một context với một classpath và một bộ property, trong khi thứ cần kiểm tra chính là class đó hành xử ra sao qua nhiều context, classpath và property khác nhau. ApplicationContextRunner — hay WebApplicationContextRunner cho một condition servlet — dựng một context dùng một lần cho mỗi kịch bản, tính bằng mili giây.
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;
}
}
}Năm method phủ năm thứ một starter buộc phải làm đúng, cùng bốn chi tiết API vốn là toàn bộ lý do công cụ này tồn tại:
withConfiguration(AutoConfigurations.of(...))đăng ký class như một auto-configuration, nên nó được xử lý sau cùng và được sắp xếp đúng như trong một application thật.withUserConfigurationđăng ký nó như một@Configurationthường, và như vậy mọi@ConditionalOnMissingBeanbên trong sẽ trả lời sai câu hỏi.withUserConfiguration(...)thì lại chính xác cho các bean của application, vì chúng đúng là như vậy.withClassLoader(new FilteredClassLoader(OncePerRequestFilter.class))giấu một class khỏi context mà không đụng tới file build. Assertion dùng tên bean, vì kiểu đó không load được trong context ấy.assertThat(context).hasFailed()là cách khẳng định một thất bại: runner bắt exception lúc khởi động thay vì ném ra, nên test soi được nó.
./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 1sCũng bốn kết cục đó, nhìn từ phía người dùng thay vì từ test:

Dùng starter từ một application
publishToMavenLocal đặt cả hai artifact vào ~/.m2/repository:
./gradlew publishToMavenLocalMột composite build — includeBuild('../request-id') trong settings.gradle của application — cũng chạy được và bỏ hẳn bước publish, đó là vòng lặp nhanh hơn khi bạn sửa library liên tục. Ở đây dùng mavenLocal() vì nó kiểm chứng đúng artifact mà người dùng thật sự tải về: jar, POM và đồ thị dependency mà POM khai báo. Gradle đọc lại mavenLocal() ở mỗi lần build: publish lại cùng version 0.0.1 rồi chạy bootJar là đủ để nhận mọi thay đổi của library trong bài này, không cần --refresh-dependencies.
Application là một project Initializr nguyên bản, thêm đúng hai dòng:
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'
}Một dòng đó kéo theo những gì, sau khi bỏ các dòng chỉ quản lý version:
./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.3Không có spring-boot-starter-webmvc ở bất kỳ đâu trong nhánh đó. Application tự chọn web stack của nó; starter không chọn thay.
Cấu hình chỉ ba dòng, trong đó một dòng còn làm id hiện ra trong log:
server.port=8201
logging.pattern.level=%5p [%X{requestId}]
requestid.service-name=ordersVà đây là toàn bộ, từ đầu đến cuối. Một request không mang id sẽ được cấp một id:
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"}Một request đã mang sẵn id thì giữ nguyên, đó là thứ khiến id sống sót qua một chặng giữa các service:
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"}Cả hai id đều vào tới MDC, nên các dòng log của chính application mang chúng theo mà controller không cần biết gì:
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/helloMột error response mang id cả trong body lẫn trên header, nhờ bean ErrorAttributes đã thắng nhờ thứ tự:
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"}Ghi đè một property
Hai property trên dòng lệnh, không build lại:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --requestid.header-name=X-Correlation-Id '--requestid.skip-paths=/actuator/**,/api/hello'/api/hello giờ nằm trong danh sách bỏ qua nên không nhận header nào:
HTTP/1.1 200
Content-Type: application/json
Content-Length: 19còn mọi đường dẫn khác dùng tên header mới:
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"}Ghi đè một bean
Khi property là chưa đủ, application khai báo bean và starter biến mất:
@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: 19Report ghi lại việc lùi lại, nêu đích danh bean gây ra:
RequestIdAutoConfiguration#requestIdFilter:
Did not match:
- @ConditionalOnMissingBean (types: com.example.requestid.RequestIdFilter; SearchStrategy: all) found beans of type 'com.example.requestid.RequestIdFilter' requestIdFilter (OnBeanCondition)Và vì bean của application chọn một order khác, nó cũng đổi chỗ trong chuỗi — từ vị trí thứ hai xuống cuối:
Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105, requestIdFilter urls=[/*] order=2147483647Nói cho người dùng biết họ sai ở đâu
requestid.service-name không có giá trị mặc định, vì một id gắn tiền tố đoán mò còn tệ hơn không có id. Một @Bean method ném exception sẽ cho ra một stack trace đúng nhưng vô dụng, nên starter đóng gói kèm một FailureAnalyzer, đúng như Boot làm cho trường hợp thiếu URL datasource. Hai class nhỏ:
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> tự đi dọc chuỗi nguyên nhân và trao cho bạn cái T nó tìm thấy, dù nó bị chôn sâu tới đâu. Hai tham số constructor trở thành hai phần của thông báo, và phần thứ hai bắt buộc phải là một chỉ dẫn, không phải một câu nói lại.
Đây là thứ duy nhất trong một starter vẫn đăng ký qua spring.factories, thứ mà Boot 4 giữ lại cho mọi thứ không phải danh sách candidate:
org.springframework.boot.diagnostics.FailureAnalyzer=\
com.example.requestid.autoconfigure.RequestIdFailureAnalyzerKhởi động application mà không đặt property đó thì output trả lời được câu hỏi của người dùng:
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.Bắt buộc một property là lựa chọn mạnh tay, và phần lớn starter nên có giá trị mặc định thay vì vậy. Khi bạn thật sự bắt buộc, một FailureAnalyzer là khác biệt giữa việc người dùng phải mở source của bạn và việc họ sửa xong trong mười giây.
Những thứ không nên làm trong một starter
Đừng component-scan. @ComponentScan trên một auto-configuration xoá sạch toàn bộ câu chuyện ghi đè: các class @Component được quét sẽ đăng ký vô điều kiện, nên @ConditionalOnMissingBean không còn chỗ bám và người dùng không thể thay bean của bạn bằng bean của họ. Mọi lần ghi đè trong bài này — filter, rồi ErrorAttributes — chạy được là vì các bean đến từ @Bean method tường minh có condition kèm theo. Tệ hơn, một lần quét luôn bắt đầu từ một package người dùng không kiểm soát, nên một base package chồng lấn sẽ lặng lẽ đăng ký luôn cả class của họ.
Đừng compile dựa trên code của người dùng. Module autoconfigure chỉ compile dựa trên Boot, SLF4J và bốn mục compileOnly, không có gì thuộc về một application. Một starter cần một kiểu dữ liệu từ application là đã đảo ngược dependency và chỉ dùng được cho đúng một application.
Đừng ép một web stack. Starter ở đây rõ ràng là chuyện HTTP, vậy mà nó vẫn không phụ thuộc spring-boot-starter-webmvc — cây dependency ở trên cho thấy nó không có mặt. spring-web và spring-boot-webmvc là compileOnly, còn @ConditionalOnClass(OncePerRequestFilter.class) biến sự vắng mặt của chúng thành một lần lùi lại sạch sẽ thay vì một cú sập, điều mà backsOffWhenSpringWebIsMissing chứng minh mà không cần đụng tới file build. Hãy để application tự chọn web framework, servlet container và thư viện JSON của nó.
Đừng apply plugin Spring Boot Gradle cho một module library. Nó sinh ra để build một application chạy được. Thêm nó vào module starter là build hỏng ngay:
> 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 classpathHãy dùng java-library và import spring-boot-dependencies dưới dạng platform, đúng như build ở root phía trên.
FAQ
Auto-configuration tự viết phải đăng ký ở đâu trong Spring Boot 4?
Trong META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports nằm trong jar của bạn, mỗi dòng một tên class đầy đủ. Khoá EnableAutoConfiguration trong META-INF/spring.factories đã bị deprecated ở Boot 2.7 và gỡ bỏ ở 3.0; trên 4.1.1 nó không có tác dụng gì. spring.factories vẫn được đọc cho các điểm mở rộng khác, đó là lý do FailureAnalyzer trong bài này đăng ký ở đó.
Vì sao bean của starter biến mất mà không có lỗi nào?
Gần như luôn luôn vì class đó không phải candidate. Chạy với --debug rồi tìm tên nó trong report: nếu nó không xuất hiện ở đâu cả — không ở Positive matches, Negative matches hay Exclusions — thì file .imports đang thiếu, rỗng, hoặc không lọt vào jar. Kiểm tra bằng unzip -l trên artifact đã publish. Còn nếu nó xuất hiện ở Negative matches, dòng Did not match: nêu đích danh condition đã loại nó.
Khác nhau giữa @AutoConfigureOrder và @Order trên một auto-configuration là gì?
@AutoConfigureOrder sắp thứ tự các class auto-configuration với nhau, và annotation processor ghi nó vào spring-autoconfigure-metadata.properties thành một mục AutoConfigureOrder. @Order không ghi gì vào đó và bộ sắp xếp không bao giờ đọc nó — nó sắp thứ tự các bean cùng kiểu, chẳng hạn các filter trong một chuỗi. Hai trục này độc lập: trong bài này, đổi before thành after làm đổi bean ErrorAttributes nào sống sót nhưng để chuỗi filter giống hệt từng byte.
Module starter có nên chứa code không?
Không. Nó chỉ có một build.gradle và không gì khác; jar đã publish ở đây nặng 25 byte manifest. Toàn bộ code, condition và metadata nằm trong module -spring-boot-autoconfigure, nhờ vậy ai muốn lấy các class mà không muốn đống dependency đi kèm thì phụ thuộc thẳng vào module đó.
Làm sao test được rằng auto-configuration lùi lại khi thiếu một class?
ApplicationContextRunner.withClassLoader(new FilteredClassLoader(TheClass.class)). Nó chỉ giấu class khỏi đúng context đó, không đụng gì tới file build, và cả test chạy trong vài mili giây. Hãy assert theo tên bean thay vì theo kiểu, vì kiểu đó có thể không load được trong context đã lọc.
spring-boot-configuration-processor có đáng thêm vào một starter không?
Có — chỉ một dòng annotationProcessor và đó là thứ mang lại cho mọi người dùng phần gợi ý, kiểu dữ liệu và giá trị mặc định cho các khoá của bạn. Nhân tiện hãy viết javadoc cho các thành phần của record @ConfigurationProperties: mỗi @param trở thành field description trong JSON sinh ra, thiếu nó thì entry phát hành mà không có lời giải thích nào.
Kết luận
Một starter gọn lại chỉ là ý tưởng của ba file cộng với rất nhiều cẩn trọng về giá trị mặc định. @AutoConfiguration là một @Configuration với proxyBeanMethods = false cùng các thuộc tính thứ tự; một dòng trong AutoConfiguration.imports là khác biệt giữa một starter chạy được và một jar lặng lẽ không làm gì; @ConditionalOnMissingBean là thứ khiến mọi bean thay thế được; before và after quyết định ai được đăng ký một bean đang tranh chấp, việc mà @Order không đụng tới; @ConfigurationProperties cùng configuration processor cho IDE của người dùng đủ mọi thứ nó cần; một SpringBootCondition đưa lập luận của bạn vào report ngay cạnh lập luận của Boot; ApplicationContextRunner test toàn bộ trong vài mili giây; và một FailureAnalyzer biến property bắt buộc duy nhất của bạn từ một stack trace thành một chỉ dẫn.
Các cơ chế nằm bên dưới đều là tính năng của container chứ không phải của Boot — một condition là Spring Framework, một @Bean method là Spring Framework, và auto-configuration chỉ là một tầng quy ước mỏng đặt lên cả hai. Bài tiếp theo ở lại đúng tầng đó và đi sâu thêm một bậc vào chính container: BeanPostProcessor và BeanFactoryPostProcessor, các interface Aware, rồi ApplicationRunner và CommandLineRunner — những điểm mở rộng bạn cần khi một bean phải được soi, viết lại, hoặc chạy đúng vào một thời điểm.