Cấu hình với @ConfigurationProperties thay cho @Value

1. Tổng quan

@Value rất tiện khi bạn chỉ cần đọc một hai giá trị cấu hình. Khi số property tăng lên, các chuỗi ${...} bắt đầu rải rác khắp các bean, khó tìm, dễ gõ sai và không được kiểm tra gì cả.

Bài này hướng dẫn bạn chuyển sang @ConfigurationProperties trong Spring Boot 3.x. Đọc xong, bạn sẽ biết cách gom cấu hình vào một record có kiểu rõ ràng và binding các kiểu như Duration, List, Map, object lồng nhau. Bạn cũng sẽ biết cách validate cấu hình ngay lúc khởi động và viết test cho phần cấu hình đó.

2. Vấn đề với @Value

Xét một PaymentService cần gọi tới cổng thanh toán:

@Service
public class PaymentService {

    @Value("${app.payment.gateway-url}")
    private String gatewayUrl;

    @Value("${app.payment.merchant-id}")
    private String merchantId;

    @Value("${app.payment.timeout:10s}")
    private Duration timeout;

    @Value("${app.payment.max-retries:3}")
    private int maxRetries;

    // ...
}

Đoạn code chạy được, nhưng có vài vấn đề:

  • Key là chuỗi tự do. Gõ app.payment.gatewayUrl thay vì gateway-url thì bạn chỉ biết lỗi khi chạy.
  • Không có validation. max-retries=-5 vẫn được inject vào bình thường.
  • Cấu hình bị phân tán. Nếu RefundService cũng cần merchant-id, bạn lại lặp lại @Value ở đó.
  • Khó test. Muốn test PaymentService mà không dựng Spring context, bạn phải dùng reflection để gán field.
  • Kiểu phức tạp rất vụng. Inject một Map<String, BigDecimal> bằng @Value đòi hỏi SpEL khá rối.

3. Khai báo lớp cấu hình với @ConfigurationProperties

3.1. Dùng record (khuyến nghị)

File application.yml:

app:
  payment:
    gateway-url: https://pay.example.com/api
    merchant-id: NONLA-001
    timeout: 5s
    max-retries: 3

Lớp cấu hình tương ứng:

package com.nonla.payment;

import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        String gatewayUrl,
        String merchantId,
        @DefaultValue("10s") Duration timeout,
        @DefaultValue("3") int maxRetries) {
}

Giải thích:

  • prefix = "app.payment" cho Spring biết record này nhận mọi key nằm dưới app.payment.
  • Spring Boot 3 tự dùng constructor binding cho record. Bạn không cần setter hay @ConstructorBinding (khi lớp chỉ có một constructor).
  • Relaxed binding ánh xạ gateway-url, gatewayUrl, GATEWAY_URL (biến môi trường APP_PAYMENT_GATEWAYURL) về cùng tham số gatewayUrl.
  • @DefaultValue cung cấp giá trị mặc định khi key không có trong cấu hình.
  • Chuỗi 5s được chuyển thành Duration.ofSeconds(5) một cách tự động.

3.2. Đăng ký và sử dụng

Record trên chưa phải là bean. Bạn cần bật nó lên ở lớp main:

@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

@ConfigurationPropertiesScan quét package hiện tại và các package con để tìm lớp có @ConfigurationProperties. Nếu muốn khai báo tường minh từng lớp, bạn dùng @EnableConfigurationProperties(PaymentProperties.class) trên một lớp @Configuration.

Bây giờ PaymentService chỉ cần inject một object:

@Service
public class PaymentService {

    private final PaymentProperties props;

    public PaymentService(PaymentProperties props) {
        this.props = props;
    }

    public String describe() {
        return "Gọi %s với merchant %s, timeout %s, retry %d lần"
                .formatted(props.gatewayUrl(), props.merchantId(),
                           props.timeout(), props.maxRetries());
    }
}

Kết quả khi gọi describe():

Gọi https://pay.example.com/api với merchant NONLA-001, timeout PT5S, retry 3 lần

PaymentService giờ là một lớp Java thuần. Trong unit test, bạn chỉ cần new PaymentService(new PaymentProperties(...)), không cần Spring.

Lưu ý: Đừng gắn @Component lên record dùng constructor binding. Khi đó Spring coi nó là bean thông thường và cố autowire String gatewayUrl như một bean khác. Ứng dụng sẽ lỗi lúc khởi động. Hãy đăng ký bằng @ConfigurationPropertiesScan hoặc @EnableConfigurationProperties.

3.3. Kiểu JavaBean có setter

Nếu cần object mutable, chẳng hạn để tương thích code cũ, bạn có thể dùng class có getter/setter:

@ConfigurationProperties(prefix = "app.mail")
public class MailProperties {
    private String host = "localhost";
    private int port = 25;

    public String getHost() { return host; }
    public void setHost(String host) { this.host = host; }
    public int getPort() { return port; }
    public void setPort(int port) { this.port = port; }
}

Spring gọi constructor mặc định, rồi gọi setter cho từng key tìm thấy. Giá trị mặc định chính là giá trị khởi tạo của field. Cách này hoạt động tốt, nhưng object có thể bị sửa ở bất kỳ đâu trong runtime. Vì vậy record vẫn là lựa chọn an toàn hơn.

4. Binding kiểu dữ liệu phức tạp

Mở rộng cấu hình thanh toán với danh sách, map và object lồng nhau:

app:
  payment:
    gateway-url: https://pay.example.com/api
    merchant-id: NONLA-001
    timeout: 5s
    max-retries: 3
    max-payload: 2MB
    supported-currencies:
      - VND
      - USD
    fees:
      card: 1.5
      wallet: 0.8
    webhook:
      enabled: true
      secret: s3cr3t
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        String gatewayUrl,
        String merchantId,
        @DefaultValue("10s") Duration timeout,
        @DefaultValue("3") int maxRetries,
        @DefaultValue("1MB") DataSize maxPayload,
        @DefaultValue({"VND"}) List<String> supportedCurrencies,
        Map<String, BigDecimal> fees,
        @DefaultValue Webhook webhook) {

    public record Webhook(
            @DefaultValue("false") boolean enabled,
            String secret) {
    }
}

Từng tham số được binding như sau:

  • DataSize hiểu các đơn vị B, KB, MB, GB. 2MB cho ra DataSize.ofMegabytes(2).
  • List<String> nhận cả dạng danh sách YAML lẫn chuỗi phân cách bằng dấu phẩy (VND,USD).
  • Map<String, BigDecimal> lấy các key con (card, wallet) làm key của map.
  • Webhook là record lồng nhau và được binding đệ quy theo prefix app.payment.webhook.
  • @DefaultValue không có giá trị, đặt trên webhook, khiến Spring tạo một Webhook với các giá trị mặc định khi toàn bộ khối webhook vắng mặt. Nếu thiếu annotation này, webhook() sẽ trả về null.

Lưu ý: Với Duration, số không kèm đơn vị được hiểu là mili giây. timeout: 30 nghĩa là 30ms chứ không phải 30 giây. Bạn nên luôn ghi đơn vị (30s, 500ms), hoặc đặt @DurationUnit(ChronoUnit.SECONDS) trên tham số.

5. Validate cấu hình khi khởi động

Thêm starter validation:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Sau đó đánh dấu @Validated và thêm các ràng buộc Jakarta Bean Validation:

@Validated
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        @NotBlank String gatewayUrl,
        @NotBlank String merchantId,
        @DefaultValue("10s") Duration timeout,
        @Min(0) @Max(10) @DefaultValue("3") int maxRetries,
        @DefaultValue("1MB") DataSize maxPayload,
        @NotEmpty @DefaultValue({"VND"}) List<String> supportedCurrencies,
        Map<String, BigDecimal> fees,
        @Valid @DefaultValue Webhook webhook) {

    public record Webhook(
            @DefaultValue("false") boolean enabled,
            String secret) {
    }
}
  • @Validated kích hoạt validation ngay sau khi binding xong.
  • @Valid trên webhook giúp các ràng buộc bên trong Webhook cũng được kiểm tra. Thiếu @Valid, Spring bỏ qua object lồng nhau.

Nếu ai đó để trống merchant-id, ứng dụng dừng ngay lúc khởi động với thông báo rõ ràng:

***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target com.nonla.payment.PaymentProperties failed:

    Property: app.payment.merchantId
    Value: ""
    Origin: class path resource [application.yml] - 4:17
    Reason: must not be blank

Action:

Update your application's configuration

Hệ thống báo lỗi ngay khi deploy, thay vì chạy được vài giờ rồi mới gãy ở request thanh toán đầu tiên. Đây là lợi ích lớn nhất so với @Value.

6. Gợi ý trong IDE với configuration processor

Thêm annotation processor:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

Khi biên dịch, processor sinh file META-INF/spring-configuration-metadata.json mô tả mọi key dưới app.payment: kiểu, giá trị mặc định và mô tả. IntelliJ IDEA và VS Code (với Spring Tools) dùng file này để gợi ý key, cảnh báo key không tồn tại và hiển thị tài liệu khi bạn di chuột vào key trong application.yml.

Mô tả của từng property được lấy từ Javadoc. Với record, bạn ghi Javadoc bằng @param:

/**
 * Cấu hình cổng thanh toán.
 *
 * @param gatewayUrl URL gốc của cổng thanh toán
 * @param maxRetries số lần thử lại tối đa khi gọi gateway thất bại
 */

Lưu ý: Nếu dự án đã khai báo annotationProcessorPaths trong maven-compiler-plugin (ví dụ cho Lombok hay MapStruct), bạn phải thêm spring-boot-configuration-processor vào danh sách đó. Nếu không, processor sẽ không chạy.

7. Test lớp cấu hình

Bạn không cần @SpringBootTest nặng nề. ApplicationContextRunner dựng một context nhỏ chỉ chứa thứ bạn cần:

package com.nonla.payment;

import static org.assertj.core.api.Assertions.assertThat;

import java.math.BigDecimal;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;

class PaymentPropertiesTest {

    @Configuration
    @EnableConfigurationProperties(PaymentProperties.class)
    static class TestConfig {
    }

    private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
            .withUserConfiguration(TestConfig.class);

    @Test
    void shouldBindValuesAndApplyDefaults() {
        contextRunner
                .withPropertyValues(
                        "app.payment.gateway-url=https://pay.example.com/api",
                        "app.payment.merchant-id=NONLA-001",
                        "app.payment.timeout=2s",
                        "app.payment.fees.card=1.5")
                .run(context -> {
                    PaymentProperties props = context.getBean(PaymentProperties.class);

                    assertThat(props.timeout()).isEqualTo(Duration.ofSeconds(2));
                    assertThat(props.maxRetries()).isEqualTo(3);
                    assertThat(props.supportedCurrencies()).containsExactly("VND");
                    assertThat(props.fees()).containsEntry("card", new BigDecimal("1.5"));
                    assertThat(props.webhook().enabled()).isFalse();
                });
    }

    @Test
    void shouldFailWhenMaxRetriesOutOfRange() {
        contextRunner
                .withPropertyValues(
                        "app.payment.gateway-url=https://pay.example.com/api",
                        "app.payment.merchant-id=NONLA-001",
                        "app.payment.max-retries=50")
                .run(context -> assertThat(context)
                        .hasFailed()
                        .getFailure()
                        .hasRootCauseInstanceOf(BindValidationException.class));
    }
}

Hai test này kiểm tra những điểm sau:

  • Test đầu xác nhận giá trị được binding đúng kiểu (2s thành Duration, 1.5 thành BigDecimal). Nó cũng xác nhận @DefaultValue được áp dụng cho maxRetries, supportedCurrencies và webhook.
  • Test thứ hai xác nhận max-retries=50 vi phạm @Max(10) và làm context khởi động thất bại với BindValidationException.

Mỗi test chạy trong vài trăm mili giây vì không có web server hay auto-configuration nào khác.

8. Khi nào vẫn dùng @Value

@ConfigurationProperties không thay thế hoàn toàn @Value. Bạn vẫn có thể dùng @Value khi:

  • Cần SpEL. Ví dụ @Value("#{${app.payment.timeout-seconds} * 1000}"). @ConfigurationProperties không hỗ trợ biểu thức SpEL.
  • Chỉ cần một giá trị lẻ, dùng đúng một chỗ. Ví dụ @Value("${spring.application.name}") trong một bean log. Tạo cả một record cho trường hợp này là thừa.

Một số lỗi hay gặp khi chuyển sang @ConfigurationProperties:

  • Quên đăng ký lớp. Thiếu @ConfigurationPropertiesScan hoặc @EnableConfigurationProperties sẽ gây lỗi “No qualifying bean of type PaymentProperties”.
  • Prefix viết sai quy ước. Prefix phải ở dạng kebab-case, chữ thường (app.payment, không phải app.Payment). Nếu sai, Spring báo lỗi ngay khi khởi động.
  • Có nhiều constructor. Khi record hoặc class có hơn một constructor, bạn phải đánh dấu constructor dùng để binding bằng @ConstructorBinding.
  • Dùng một lớp cho mọi thứ. Đừng nhồi toàn bộ cấu hình ứng dụng vào một AppProperties khổng lồ. Hãy chia theo miền nghiệp vụ: PaymentProperties, MailProperties, StorageProperties…

Lưu ý: Record cấu hình là immutable. Nếu bạn dùng Spring Cloud để refresh cấu hình lúc runtime, bean phải được tạo lại (qua @RefreshScope hoặc cơ chế rebind) thì giá trị mới mới có hiệu lực. Đừng kỳ vọng record tự thay đổi.

9. Kết luận

Trong bài này, bạn đã thay các @Value rời rạc bằng một record PaymentProperties có kiểu rõ ràng. Cụ thể, bạn đã:

  • binding được Duration, DataSize, List, Map và record lồng nhau, kèm giá trị mặc định qua @DefaultValue;
  • validate cấu hình ngay lúc khởi động bằng @Validated cùng các ràng buộc Jakarta;
  • có gợi ý key trong IDE nhờ spring-boot-configuration-processor;
  • test phần cấu hình nhanh gọn bằng ApplicationContextRunner.

Với bất kỳ nhóm cấu hình nào có từ hai ba key trở lên, @ConfigurationProperties nên là lựa chọn mặc định. Hãy để dành @Value cho các giá trị lẻ hoặc khi bạn thực sự cần SpEL.

spring-bootconfigurationconfigurationpropertiesvalidationjava