Spring Core & Boot/@ConfigurationProperties vs @Value — bind config type-safe
36/41
Bài 36 / 41~12 phútConfig, Profiles & LoggingMiễn phí lượt xem

@ConfigurationProperties vs @Value — bind config type-safe

Hai cách inject externalized config vào Spring bean: @Value cho 1 giá trị đơn, @ConfigurationProperties để bind cả nhóm property thành object type-safe, có validation, IDE autocomplete, và relax binding. Bài này giải thích cơ chế binding bên dưới, khi nào chọn cái nào, và vì sao @ConfigurationProperties record + @Validated là pattern production 2026.

TL;DR: @Value("${key}") inject đúng 1 giá trị, hỗ trợ SpEL, nhưng là string literal — không có type-safety, không validate, không IDE autocomplete. @ConfigurationProperties(prefix="app") bind cả nhóm property vào một object Java, tự convert kiểu (int, Duration, List, nested record), validate qua Jakarta Validation, sinh metadata cho IDE. Dùng @Value chỉ khi cần SpEL hoặc inject 1-2 property standalone. File application-{profile}.yml override application.yml khi profile đó active — non-secret commit git, secret qua env var. Chốt lại: record + @ConfigurationProperties + @Validated = immutable, fail-fast tại startup — pattern production 2026.

Bài PropertySource & binding giải thích Spring biết lấy config từ đâu — 17 nguồn PropertySource, thứ tự ưu tiên. Bài này trả lời câu hỏi kế tiếp: sau khi Spring đã load property vào Environment, code Java lấy giá trị ra bằng cách nào, và cách nào nên dùng?

1. Hai định dạng file — properties vs YAML

Spring Boot đọc config từ application.properties hoặc application.yml. Cùng một cấu hình, hai cách viết:

application.properties — flat key-value:

app.name=OLHub
app.max-orders=500
app.allowed-origins=https://olhub.org,https://www.olhub.org
app.database.url=jdbc:postgresql://localhost/app
app.database.pool-size=30

application.yml — hierarchical:

app:
  name: OLHub
  max-orders: 500
  allowed-origins:
    - https://olhub.org
    - https://www.olhub.org
  database:
    url: jdbc:postgresql://localhost/app
    pool-size: 30
AspectPropertiesYAML
Cấu trúcFlat — lặp prefix ở mỗi dòngHierarchical — nhóm lồng nhau
Listkey[0]=a, key[1]=b- a, - b
Kiểu dữ liệuMọi thứ là stringNative: string, number, bool, list, map
Indent riskKhôngIndent sai → parse fail âm thầm
Phù hợpFile ngắn, tool không support YAMLFile lớn hơn 20 dòng, nested config

Cảnh báo YAML — indent sai là pitfall phổ biến nhất:

# SAI — hikari indent 3 space thay 4 → parse fail
spring:
  datasource:
    url: jdbc:postgresql://localhost/app
   hikari:          # 3 space: YAML parse nhu sibling, khong phai child
      maximum-pool-size: 20

# DUNG — indent nhat quan
spring:
  datasource:
    url: jdbc:postgresql://localhost/app
    hikari:         # 4 space: child cua datasource
      maximum-pool-size: 20

Boot merge cả hai format nếu cùng tồn tại, nhưng nên dùng một format duy nhất — trộn lẫn gây nhầm lẫn.

2. Profile-specific file — override theo môi trường

Spring Boot hỗ trợ cơ chế profile-specific config file: khi profile prod active, Boot load application.yml trước, sau đó load application-prod.ymloverride mọi property trùng. Property không có trong application-prod.yml vẫn giữ giá trị từ application.yml.

src/main/resources/
  application.yml           -- default cho moi profile
  application-dev.yml       -- override khi dev active
  application-staging.yml   -- override khi staging active
  application-prod.yml      -- override khi prod active

Activate profile qua:

# Command line
java -jar app.jar --spring.profiles.active=prod

# Env var (K8s/Docker standard)
SPRING_PROFILES_ACTIVE=prod java -jar app.jar

Ví dụ thực tế:

# application.yml (commit git)
app:
  name: OLHub
  max-orders: 100
  database:
    url: jdbc:postgresql://localhost/dev
    pool-size: 5
# application-prod.yml (commit git, non-secret)
app:
  max-orders: 2000
  database:
    url: jdbc:postgresql://prod-db.internal:5432/app
    pool-size: 50
# K8s Secret inject qua env var (KHONG commit)
# SPRING_DATASOURCE_PASSWORD=... -> spring.datasource.password
# APP_DATABASE_PASSWORD=...      -> app.database.password

Khi profile prod active: app.name lấy từ application.yml ("OLHub"), còn app.max-ordersapp.database.* lấy từ application-prod.yml. Xem chi tiết cơ chế profile activation và @Profilebài Profiles.

Pattern production 2026

Non-secret (URL nội bộ, pool size, timeout) commit vào application-prod.yml. Secret (password, API key) inject qua env var từ K8s Secret hoặc Vault — không bao giờ commit secret vào git.

3. @Value — inject một giá trị

@Value là cách đơn giản nhất để lấy một property từ Environment vào một field:

@Service
public class NotificationService {

    @Value("${app.name}")                          // required, fail-fast neu thieu
    private String appName;

    @Value("${app.max-orders:100}")               // default 100 neu thieu
    private int maxOrders;

    @Value("${app.allowed-origins}")               // Spring tu convert CSV → List
    private List<String> allowedOrigins;

    @Value("#{systemProperties['user.timezone']}") // SpEL: doc System Property
    private String timezone;

    @Value("#{T(java.time.Instant).now()}")        // SpEL: goi static method
    private Instant startedAt;
}

SpEL (Spring Expression Language) — cú pháp #{...} — cho phép gọi method, tính toán, đọc bean khác. Đây là khả năng @ConfigurationProperties không có.

Khi nào dùng @Value:

  • Inject 1-2 property standalone, không liên quan nhau.
  • Cần SpEL để tính toán giá trị động (timestamp, system property, bean expression).

Hạn chế của @Value:

// Anti-pattern: 8 @Value trong mot class
@Service
public class EmailService {
    @Value("${app.email.host}")    private String host;
    @Value("${app.email.port}")    private int port;
    @Value("${app.email.user}")    private String user;
    @Value("${app.email.pass}")    private String pass;
    @Value("${app.email.tls}")     private boolean tls;
    @Value("${app.email.timeout}") private Duration timeout;
    // ... 2 field nua
}

Vấn đề: string literal dễ typo, không IDE autocomplete, không validate khi thiếu, khó refactor khi đổi tên property, scatter khắp nhiều class.

4. @ConfigurationProperties — bind nhóm thành object

@ConfigurationProperties bind cả một nhóm property có chung prefix thành một object Java — type-safe, validate, refactor-friendly.

4.1 Class truyền thống

@ConfigurationProperties(prefix = "app.email")
@Validated                                   // bat validation Bean Validation
public class EmailProperties {

    @NotBlank
    private String host;

    @Min(1) @Max(65535)
    private int port = 587;                  // default value

    @NotBlank
    private String username;

    @NotBlank
    private String password;

    private boolean tlsEnabled = true;

    @NotNull
    private Duration timeout = Duration.ofSeconds(30);

    // getters + setters bat buoc (Boot bind qua setter)
    public String getHost() { return host; }
    public void setHost(String host) { this.host = host; }
    // ...
}

4.2 Record — pattern 2026 chuẩn

Java 17 records giúp @ConfigurationProperties cực kỳ gọn — không cần getter/setter, immutable by default:

@ConfigurationProperties(prefix = "app.email")
@Validated
public record EmailProperties(
    @NotBlank String host,
    @Min(1) @Max(65535) int port,
    @NotBlank String username,
    @NotBlank String password,
    boolean tlsEnabled,
    @NotNull Duration timeout
) {
    // Compact constructor: set default cho optional field
    public EmailProperties {
        if (port == 0) port = 587;
        if (timeout == null) timeout = Duration.ofSeconds(30);
    }
}

Tương đương với class 40+ dòng, chỉ cần 12 dòng.

4.3 Config YAML tương ứng

app:
  email:
    host: ${SMTP_HOST}
    port: ${SMTP_PORT:587}
    username: ${SMTP_USER}
    password: ${SMTP_PASS}
    tls-enabled: true
    timeout: PT30S          # ISO-8601 Duration: 30 giay

Boot tự convert: string "PT30S"Duration.ofSeconds(30). Relax binding tự map tls-enabled (kebab-case trong YAML) sang field tlsEnabled (camelCase trong Java).

4.4 Đăng ký bean

@ConfigurationProperties không tự register — phải thêm một trong hai:

// Cach 1: scan toan project (khuyen nghi)
@SpringBootApplication
@ConfigurationPropertiesScan
public class App { ... }

// Cach 2: register cu the
@SpringBootApplication
@EnableConfigurationProperties(EmailProperties.class)
public class App { ... }

Quên đăng ký → NoSuchBeanDefinitionException khi inject.

4.5 Sử dụng

@Service
public class EmailService {
    private final EmailProperties config;

    public EmailService(EmailProperties config) {   // constructor injection
        this.config = config;
    }

    public void send(String to, String subject, String body) {
        // Su dung: config.host(), config.port(), v.v.
        JavaMailSenderImpl sender = new JavaMailSenderImpl();
        sender.setHost(config.host());
        sender.setPort(config.port());
        sender.getJavaMailProperties()
              .setProperty("mail.smtp.starttls.enable", String.valueOf(config.tlsEnabled()));
    }
}

5. Cơ chế bên dưới — binding xảy ra như thế nào

Hiểu bên dưới giúp debug khi binding không đúng — đây không phải annotation magic.

flowchart TB
    subgraph Startup["Startup: ApplicationContext refresh()"]
        direction TB
        A["ConfigDataEnvironmentPostProcessor<br/>load application.yml + application-prod.yml<br/>vao Environment (PropertySource chain)"]
        B["ConfigurationPropertiesBindingPostProcessor<br/>la BeanFactoryPostProcessor chay sau A"]
        C["Voi moi bean co @ConfigurationProperties:<br/>doc prefix, tra PropertySource chain,<br/>convert kieu, validate"]
        A --> B --> C
    end
    D["EmailProperties bean<br/>san sang inject"]
    C --> D

Hai actor chính:

ConfigDataEnvironmentPostProcessor (chạy rất sớm, trước tạo bean): load application.yml, profile-specific files, env var vào Environment theo thứ tự ưu tiên thành chain PropertySource. Kết quả là Environment đã có tất cả key-value.

ConfigurationPropertiesBindingPostProcessor (là BeanFactoryPostProcessor): sau khi scan tìm mọi class @ConfigurationProperties, với mỗi class nó:

  1. Đọc prefix từ annotation.
  2. Tra Environment lấy tất cả property có prefix đó.
  3. Convert kiểu qua ConversionService — string "PT30S"Duration, "true"boolean, v.v.
  4. Gọi setter (class truyền thống) hoặc bind qua constructor (record).
  5. Nếu có @Validated — gọi Bean Validation. Vi phạm → BindValidationException → app không start.

Relax binding: Boot chuẩn hóa tên property về canonical form trước khi so sánh. tls-enabled (YAML), tlsEnabled (camelCase Java), TLS_ENABLED (env var), tls_enabled (snake_case) — tất cả map vào cùng một field. Đây là lý do env var K8s APP_EMAIL_TLS_ENABLED match app.email.tls-enabled mà không cần config thêm.

flowchart LR
    E1["YAML: tls-enabled"]
    E2["Env var: APP_EMAIL_TLS_ENABLED"]
    E3["Java prop: app.email.tlsEnabled"]
    N["Canonical:<br/>app.email.tls-enabled"]
    F["Field: boolean tlsEnabled"]
    E1 --> N
    E2 --> N
    E3 --> N
    N --> F

Xem thêm về EnvironmentPropertySource chain ở bài Environment & PropertySource.

6. Vì sao ưu tiên @ConfigurationProperties hơn @Value

Khía cạnh@Value@ConfigurationProperties
Type-safeChỉ string/primitiveNested object, List, Map, Duration, enum
ValidationKhôngJakarta Validation (@NotBlank, @Min, @Max)
Fail-fastKhông (NPE runtime)Có — BindValidationException tại startup
IDE autocompleteKhôngCó (qua spring-boot-configuration-processor)
Relax bindingKhôngCó — kebab/camel/UPPER_SNAKE tự map
RefactorString literal khó renameJava field — IDE rename propagates
SpELKhông
Setup overhead01 class + đăng ký
Best for1-2 prop, SpELMọi config domain-specific

Quy tắc thực hành:

Mặc định dùng @ConfigurationProperties. Chỉ dùng @Value khi cần SpEL hoặc inject 1-2 property thực sự standalone (vd: @Value("${server.port}") trong một utility method).

IDE autocomplete là lợi thế thực tế lớn. Thêm dependency:

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

Annotation processor đọc class @ConfigurationProperties lúc compile, sinh META-INF/spring-configuration-metadata.json. IntelliJ và VSCode đọc file này → gợi ý tên property khi gõ trong YAML + show Javadoc + warn typo.

7. Pitfall

Nhầm 1 — Thiếu @Validated → NPE runtime thay vì fail-fast:

// SAI
@ConfigurationProperties(prefix = "app")
public record AppProps(String apiUrl) {}

// DUNG
@ConfigurationProperties(prefix = "app")
@Validated
public record AppProps(@NotBlank String apiUrl) {}

Không có @Validated: app.api-url thiếu trong YAML → apiUrl = null → NPE runtime sau khi app đã start xong và đang serve traffic. Với @Validated: Boot throw BindValidationException tại startup, app không start, K8s health check fail → pod không được đưa vào load balancer. Đây là fail-fast có chủ đích.

Nhầm 2 — Quên đăng ký class:

// SAI — EmailProperties la bean nhung khong ai biet
@ConfigurationProperties(prefix = "app.email")
public record EmailProperties(String host) {}

// DUNG — them @ConfigurationPropertiesScan o App
@SpringBootApplication
@ConfigurationPropertiesScan
public class App { ... }

Quên @ConfigurationPropertiesScan hoặc @EnableConfigurationProperties → record không bind, inject fail với NoSuchBeanDefinitionException.

Nhầm 3 — @Value với property phức tạp:

// SAI — khong type-safe, split thu cong
@Value("${app.allowed-origins}")
private String originsRaw;   // "https://a.com,https://b.com"
List<String> origins = Arrays.asList(originsRaw.split(","));

// DUNG — @ConfigurationProperties tu convert
@ConfigurationProperties(prefix = "app")
public record AppProps(List<String> allowedOrigins) {}
app:
  allowed-origins:
    - https://a.com
    - https://b.com

Nhầm 4 — Secret hardcode trong YAML:

# SAI — commit len git = security incident
app:
  email:
    password: smtp-secret-2026

# DUNG — reference env var
app:
  email:
    password: ${SMTP_PASS}   # required, fail neu thieu

Liên hệ các bài khác

  • PropertySource & binding: cơ chế Environment load property từ đâu, thứ tự ưu tiên 17 nguồn, cách @Value resolve ${key} qua PropertySource chain — đọc trước để hiểu binding trong bài này lấy dữ liệu từ đâu.
  • Profiles: application-{profile}.yml được load như thế nào, @Profile annotation, profile group — bài này giải thích activation logic và profile-specific bean tương ứng với section 2 bài này.
  • Environment & PropertySource: Environment interface, PropertySource chain bên dưới container, cách ConfigurableEnvironment được xây dựng — cơ chế source mà ConfigurationPropertiesBindingPostProcessor tra để bind.

Tóm tắt

  • application.properties (flat) và application.yml (hierarchical) — Boot đọc cả hai; YAML tốt hơn cho config lồng nhau nhiều key.
  • Profile-specific file application-prod.yml override application.yml khi profile prod active. Non-secret commit git, secret qua env var.
  • @Value("${key}"): inject 1 giá trị, hỗ trợ SpEL (#{...}), không validate, không autocomplete — dùng cho 1-2 property đơn giản hoặc SpEL.
  • @ConfigurationProperties(prefix="..."): bind cả nhóm → object Java, type-safe, relax binding tự map kebab/camelCase/UPPER_SNAKE, validate qua @Validated + Jakarta Validation.
  • Record + @ConfigurationProperties + @Validated = pattern 2026: immutable, fail-fast tại startup, không setter boilerplate.
  • Cơ chế: ConfigurationPropertiesBindingPostProcessor (BeanFactoryPostProcessor) tra Environment, convert kiểu qua ConversionService, validate — toàn bộ xảy ra trước khi bean khác được tạo.
  • Thêm spring-boot-configuration-processor (optional) → sinh metadata → IDE autocomplete property trong YAML.
  • Mặc định ưu tiên @ConfigurationProperties. @Value chỉ khi cần SpEL.

Tự kiểm tra

Tự kiểm tra
Q1
Vì sao @ConfigurationProperties kết hợp @Validated được gọi là "fail-fast", trong khi thiếu validation thì app lại "fail-late"? Hệ quả thực tế của fail-late là gì?

Với @Validated: khi app khởi động, ConfigurationPropertiesBindingPostProcessor bind property rồi chạy Bean Validation ngay. Nếu thiếu @NotBlank String hostBindValidationException → app không start. K8s health check fail → pod không vào load balancer → zero traffic impact.

Không có @Validated: thiếu property → field = null → app start bình thường. NPE xảy ra khi code thực sự gọi field đó — có thể là request đầu tiên, có thể là giờ cao điểm. Đây là fail-late: downtime + customer impact.

Fail-fast là lựa chọn thiết kế có chủ đích: "thà không start được còn hơn start rồi crash". Quy tắc 2026: mọi @ConfigurationProperties domain-critical phải có @Validated + constraint annotation trên mọi required field.

Q2
Cơ chế relax binding trong Spring Boot là gì? Giải thích tại sao env var K8s APP_EMAIL_TLS_ENABLED=true tự động match field boolean tlsEnabled trong record @ConfigurationProperties(prefix="app.email") mà không cần config thêm.

Relax binding là cơ chế Boot chuẩn hóa tên property về canonical form trước khi so sánh với field. Canonical form là kebab-case lowercase: app.email.tls-enabled.

Quá trình với env var APP_EMAIL_TLS_ENABLED:

  1. Boot nhận env var: APP_EMAIL_TLS_ENABLED.
  2. Convert UPPER_SNAKE: lowercase + thay _ bằng .app.email.tls.enabled. Vì prefix là app.email, phần còn lại là tls.enabled → canonical tls-enabled.
  3. Match với field tlsEnabled trong record (camelCase → canonical tls-enabled). Trùng → bind giá trị.

Tương tự, YAML tls-enabled (kebab) và Java property tlsEnabled (camelCase) đều map vào cùng field. Dev không cần viết mapping thủ công — Boot lo chuẩn hóa. Lợi ích thực tế: K8s dùng UPPER_SNAKE cho env var (convention hệ thống), còn YAML và Java code dùng kebab/camelCase — relax binding khớp ba phong cách mà không friction.

Q3
Khi nào nên dùng @Value thay vì @ConfigurationProperties? Cho 1 ví dụ cụ thể cho mỗi trường hợp.

Dùng @Value khi:

  • Cần SpEL (#{...}) để tính giá trị động — @ConfigurationProperties không hỗ trợ SpEL.
  • Inject 1-2 property thực sự standalone, không liên quan nhau.

Ví dụ @Value phù hợp:

@Component
public class AppInfo {
  // SpEL: doc system property tai runtime
  @Value("#{systemProperties['user.timezone']}")
  private String timezone;

  // SpEL: lay version tu manifest
  @Value("#{T(java.lang.Package).getPackage('com.olhub')?.getImplementationVersion() ?: 'dev'}")
  private String version;
}

Dùng @ConfigurationProperties khi: có 3+ property cùng prefix, cần type-safe (Duration, List, nested object), hoặc cần validation.

Ví dụ @ConfigurationProperties phù hợp:

@ConfigurationProperties(prefix = "app.payment")
@Validated
public record PaymentProperties(
  @NotBlank String gatewayUrl,
  @NotBlank String apiKey,
  @Min(1) int retryCount,
  @NotNull Duration timeout,
  List<String> supportedCurrencies
) {}

5 property cùng prefix — type-safe (Duration, List), validate tại startup, IDE autocomplete. @Value cho 5 field sẽ dài, dễ typo, không validate.

Q4
Mô tả luồng hoạt động của ConfigurationPropertiesBindingPostProcessor: nó chạy ở giai đoạn nào trong quá trình khởi động, làm gì, và tại sao phải là BeanFactoryPostProcessor chứ không phải BeanPostProcessor?

ConfigurationPropertiesBindingPostProcessorBeanFactoryPostProcessor — chạy sau khi tất cả BeanDefinition đã được đăng ký nhưng trước khi bất kỳ bean application nào được instantiate.

Luồng hoạt động:

  1. Scan tất cả BeanDefinition tìm class có annotation @ConfigurationProperties.
  2. Với mỗi class: đọc prefix từ annotation, tra Environment (PropertySource chain đã được ConfigDataEnvironmentPostProcessor load sẵn) lấy tất cả property có prefix đó.
  3. Convert kiểu qua ConversionService: string "PT30S"Duration, "true"boolean, YAML list → List<String>.
  4. Bind giá trị: setter cho class truyền thống, constructor binding cho record.
  5. Nếu có @Validated: chạy Bean Validation. Vi phạm → BindValidationException → context refresh fail → app không start.

Tại sao phải là BeanFactoryPostProcessor? Vì config bean cần sẵn sàng trước khi bean khác dùng nó được tạo. Nếu là BeanPostProcessor (chạy sau khi bean được tạo), các service inject EmailProperties sẽ bị tạo trước khi EmailProperties được bind — dẫn đến inject null. BeanFactoryPostProcessor đảm bảo config object đã bind đầy đủ trước khi bất kỳ application bean nào ra đời.

Q5
Record @ConfigurationProperties sau có 3 vấn đề. Xác định từng vấn đề và viết code đúng.
@ConfigurationProperties(prefix = "app.db")
public record DbProperties(
  String url,
  String username,
  String password,
  int poolSize
) {}

// App.java
@SpringBootApplication
public class App { ... }

3 vấn đề:

  1. Không validate: nếu app.db.url thiếu trong config, field url = null → NPE runtime khi code dùng đến, không fail-fast tại startup. Fix: thêm @Validated + annotation constraint.
  2. Không đăng ký bean: @ConfigurationProperties không tự register. Thiếu @ConfigurationPropertiesScan hoặc @EnableConfigurationPropertiesNoSuchBeanDefinitionException khi inject. Fix: thêm @ConfigurationPropertiesScan vào App.
  3. poolSize không có default: int primitive không có default trong record → bind = 0 nếu không set, không có validation. Fix: dùng compact constructor để set default, hoặc dùng @Min(1) + set default trong YAML.

Code đúng:

@ConfigurationProperties(prefix = "app.db")
@Validated
public record DbProperties(
  @NotBlank String url,
  @NotBlank String username,
  @NotBlank String password,
  @Min(1) @Max(200) int poolSize
) {
  public DbProperties {
      if (poolSize == 0) poolSize = 10;   // default neu khong set
  }
}

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

YAML tương ứng:

app:
db:
  url: ${DB_URL}               # required
  username: ${DB_USER}         # required
  password: ${DB_PASS}         # required
  pool-size: ${DB_POOL:10}     # default 10

Pattern này đảm bảo: app không start nếu thiếu DB_URL, DB_USER, DB_PASS. Lỗi rõ ràng tại startup thay vì NPE lúc request đầu tiên.

Bài tiếp theo: Profiles — activation & @Profile

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên