Command Palette

Search for a command to run...

[Advanced Spring Boot] Tự viết auto-configuration và starter cho Spring Boot

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ó.

Một jar mang theo @AutoConfiguration, AutoConfiguration.imports và file metadata JSON, tạo ra bean đã cấu hình sẵn trong mọi application

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ề.

Tree
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

Ba Gradle project, mỗi cái chứa gì, jar của mỗi cái có gì, và dependency chỉ theo hướng nào

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.

request-id/settings.gradle
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ế.

request-id/build.gradle
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:

Text
> 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:

request-id/request-id-spring-boot-autoconfigure/build.gradle
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 OncePerRequestFilterDefaultErrorAttributes để 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:

request-id/request-id-spring-boot-starter/build.gradle
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:

Bash
unzip -l ~/.m2/repository/com/example/request-id-spring-boot-starter/0.0.1/request-id-spring-boot-starter-0.0.1.jar
Text
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  02-01-1980 00:00   META-INF/
       25  02-01-1980 00:00   META-INF/MANIFEST.MF
---------                     -------
       25                     2 files

Hai 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.

request-id/request-id-spring-boot-autoconfigure/src/main/java/com/example/requestid/autoconfigure/RequestIdAutoConfiguration.java
@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:

AnnotationVì 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-webcompileOnly, 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
@EnableConfigurationPropertiesbind 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.

⚠️ @ConditionalOnBooleanProperty là 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 @ConditionalOnProperty với havingValue = "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:

Java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration(proxyBeanMethods = false)
@AutoConfigureBefore
@AutoConfigureAfter
public @interface AutoConfiguration {

Nó mang theo ba thứ. Nó 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@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ể beforeNameafterName 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:

request-id/request-id-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.example.requestid.autoconfigure.RequestIdAutoConfiguration

Tê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:

Bash
grep -c RequestId run.log
Text
0

Khô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 đó:

Text
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:

Text
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:

Text
   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:

Java
@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.

Ba jar đóng góp file .imports, thứ tự sau khi sắp xếp, và bean ErrorAttributes nào sống sót

Với before, bean của chúng ta chạy trước và bean của Boot lùi lại. Report:

Text
   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)
Bash
curl -s http://localhost:8201/api/nope
Text
{"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:

RequestIdAutoConfiguration.java
@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 đó:

Text
   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)
Text
{"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:

build/classes/java/main/META-INF/spring-autoconfigure-metadata.properties
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:

Properties
com.example.requestid.autoconfigure.RequestIdAutoConfiguration.AutoConfigureBefore=org.springframework.boot.webmvc.autoconfigure.error.ErrorMvcAutoConfiguration
com.example.requestid.autoconfigure.RequestIdAutoConfiguration.AutoConfigureOrder=-2147483618

Thay 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, AutoConfigureBeforeAutoConfigureAfter, 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-configurationtrên class auto-configuration
@AutoConfigureOrdercũng thứ đó, nhưng bằng con số thay vì quan hệtrên class auto-configuration
@Order / Orderedvị trí của một bean giữa các bean cùng kiểutrê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:

Text
Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, requestIdFilter urls=[/*] order=-2147483628, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105

Và đây là dòng đó ở lần chạy after — lần mà thứ tự auto-configuration đã đổi và bên thắng ErrorAttributes đã đảo:

Text
Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, requestIdFilter urls=[/*] order=-2147483628, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105

Giố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ỗ.

request-id/request-id-spring-boot-autoconfigure/src/main/java/com/example/requestid/RequestIdProperties.java
/**
 * 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ó:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar '--requestid.header-name=X Request Id'
Text
***************************
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 configuration

spring-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:

META-INF/spring-configuration-metadata.json
{
  "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:

demo/src/main/resources/application.properties
requestid.service-name=orders
requestid.header-name=X-Request-Id
requestid.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:

request-id/request-id-spring-boot-autoconfigure/src/main/java/com/example/requestid/autoconfigure/OnMissingTracingCondition.java
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(...)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:

Text
   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:

Text
   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:

Text
   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.

request-id/request-id-spring-boot-autoconfigure/src/test/java/com/example/requestid/autoconfigure/RequestIdAutoConfigurationTests.java
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 @Configuration thường, và như vậy mọi @ConditionalOnMissingBean bê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ó.
Bash
./gradlew :request-id-spring-boot-autoconfigure:test --rerun-tasks
Text
RequestIdAutoConfigurationTests > backsOffWhenDisabled() PASSED
 
RequestIdAutoConfigurationTests > registersTheFilterByDefault() PASSED
 
RequestIdAutoConfigurationTests > backsOffWhenSpringWebIsMissing() PASSED
 
RequestIdAutoConfigurationTests > failsWhenNoServiceNameIsConfigured() PASSED
 
RequestIdAutoConfigurationTests > backsOffWhenTheApplicationDeclaresItsOwnFilter() PASSED
 
BUILD SUCCESSFUL in 1s

Cũng bốn kết cục đó, nhìn từ phía người dùng thay vì từ test:

Cùng một starter khởi động theo bốn cách, bean mà mỗi context nhận được, header trả về và dòng report

Dùng starter từ một application

publishToMavenLocal đặt cả hai artifact vào ~/.m2/repository:

Bash
./gradlew publishToMavenLocal

Mộ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:

demo/build.gradle
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:

Bash
./gradlew dependencies --configuration runtimeClasspath
Text
\--- 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.3

Khô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:

demo/src/main/resources/application.properties
server.port=8201
logging.pattern.level=%5p [%X{requestId}]
 
requestid.service-name=orders

Và đây là toàn bộ, từ đầu đến cuối. Một request không mang id sẽ được cấp một id:

Bash
curl -s -D - http://localhost:8201/api/hello
Text
HTTP/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:

Bash
curl -s -D - -H 'X-Request-Id: from-gateway-42' http://localhost:8201/api/hello
Text
HTTP/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ì:

Text
 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/hello

Một error response mang id cả trong body lẫn trên header, nhờ bean ErrorAttributes đã thắng nhờ thứ tự:

Text
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:

Bash
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:

Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 19

còn mọi đường dẫn khác dùng tên header mới:

Text
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:

demo/src/main/java/com/example/demo/RequestIdConfig.java
@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;
    }
}
Text
HTTP/1.1 200 
X-Trace-Id: checkout-373d8c14-ec6e
Content-Type: application/json
Content-Length: 19

Report ghi lại việc lùi lại, nêu đích danh bean gây ra:

Text
   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:

Text
Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105, requestIdFilter urls=[/*] order=2147483647

Nó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ỏ:

request-id/request-id-spring-boot-autoconfigure/src/main/java/com/example/requestid/autoconfigure/RequestIdServiceNameMissingException.java
public class RequestIdServiceNameMissingException extends RuntimeException {
 
    public RequestIdServiceNameMissingException() {
        super("requestid.service-name is not set");
    }
}
request-id/request-id-spring-boot-autoconfigure/src/main/java/com/example/requestid/autoconfigure/RequestIdFailureAnalyzer.java
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:

request-id/request-id-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories
org.springframework.boot.diagnostics.FailureAnalyzer=\
com.example.requestid.autoconfigure.RequestIdFailureAnalyzer

Khởi động application mà không đặt property đó thì output trả lời được câu hỏi của người dùng:

Text
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-webspring-boot-webmvccompileOnly, 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:

Text
> 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 classpath

Hã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; beforeafter 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: BeanPostProcessorBeanFactoryPostProcessor, các interface Aware, rồi ApplicationRunnerCommandLineRunner — 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.

Bài viết liên quan

[Advanced Spring Boot] Tự dựng authorization server: Spring Authorization Server và Keycloak

Dựng một authorization server OAuth2 và OpenID Connect bằng Spring Authorization Server, nay là một module của Spring Security, trên Spring Boot 4.1.1: starter mà Initializr chọn và starter đã deprecated, một server chỉ từ property, hai discovery document và các endpoint chúng công bố, hai filter chain Boot đăng ký và những gì thay đổi khi bạn tự khai báo, client_credentials và authorization_code với PKCE từng bước, body lỗi thật, RSA key đổi sau mỗi lần restart, key cố định và key rotation với JWK selector, JWKS cache của resource server, client, authorization và consent lưu bằng JDBC trên PostgreSQL với schema script lấy từ jar, claim roles từ OAuth2TokenCustomizer và cái bẫy allowlist của Jackson, opaque token với introspection được đo so với việc validate JWT, và phép so sánh có đo đạc với Keycloak.

[Advanced Spring Boot] Multi-tenancy và soft delete với Spring Boot và Hibernate

Multi-tenancy và soft delete trên Spring Boot 4.1.1 với Hibernate và PostgreSQL: filter đọc X-Tenant-Id với ThreadLocal bị rò rỉ trên thread Tomcat được tái sử dụng và bị mất trên @Async, discriminator column với @TenantId và CurrentTenantIdentifierResolver (predicate tenant trên find, JPQL, derived query, Specification và bulk update, không có trên native SQL hay JdbcClient), schema per tenant với MultiTenantConnectionProvider, bẫy tái sử dụng connection giữa setSchema và SET search_path trên HikariCP, Flyway cho từng tenant schema và TenantSchemaMapper của Hibernate, database per tenant với 100 connection cho mười tenant, row-level security của PostgreSQL với set_config và FORCE, các strategy của @SoftDelete so với @SQLDelete và @SQLRestriction, lỗi to-one LAZY, partial unique index cho SKU đã soft delete, và khôi phục các dòng đã xóa.

[Advanced Spring Boot] Spring AOP: proxy JDK và CGLIB, aspect và lỗi self-invocation

Spring AOP trên Spring Boot 4.1.1: spring-boot-starter-aop đã biến mất khỏi BOM và spring-boot-starter-aspectj thay thế nó, JDK dynamic proxy so với CGLIB subclass kèm tên class thật, ClassCastException mà JDK proxy gây ra, class final ném AopConfigException, method final âm thầm NPE vì Objenesis bỏ qua constructor, các pointcut designator đáng dùng, thứ tự đo được của cả năm loại advice trên hai nhánh, @Order giữa các aspect, lỗi self-invocation nằm dưới @Transactional và @Async cùng ba cách sửa được so sánh, chi phí nanosecond của một lời gọi qua proxy, và Advised#getAdvisors để debug.

[Advanced Spring Boot] OAuth2 và OpenID Connect trong Spring Boot: OAuth2 Login và resource server với JWT

OAuth2 và OpenID Connect với Spring Security trên Spring Boot 4.1.1 và Keycloak: realm import từ file JSON, discovery qua issuer-uri và lỗi khởi động khi Keycloak tắt, authorization code flow với PKCE (S256, bật sẵn cho confidential client) từng bước một, authorization code bị đánh cắp bị từ chối vì thiếu verifier, ID token và access token giải mã cạnh nhau, OidcUser với các authority OIDC_USER và SCOPE_, user-name-attribute, realm role của Keycloak map thành ROLE_ bằng GrantedAuthoritiesMapper, RP-initiated logout với OidcClientInitiatedLogoutSuccessHandler, Google và GitHub qua CommonOAuth2Provider, resource server JWT với issuer-uri và key được tải lười, header WWW-Authenticate cho token từ realm khác, sai issuer, bị sửa chữ ký, sai audience và hết hạn, realm_access.roles map bằng authorities-claim-expressions, token relay với OAuth2ClientHttpRequestInterceptor và token client credentials dùng lại qua nhiều lần gọi.