@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
| Aspect | Properties | YAML |
|---|---|---|
| Cấu trúc | Flat — lặp prefix ở mỗi dòng | Hierarchical — nhóm lồng nhau |
| List | key[0]=a, key[1]=b | - a, - b |
| Kiểu dữ liệu | Mọi thứ là string | Native: string, number, bool, list, map |
| Indent risk | Không | Indent sai → parse fail âm thầm |
| Phù hợp | File ngắn, tool không support YAML | File 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.yml và override 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-orders và app.database.* lấy từ application-prod.yml. Xem chi tiết cơ chế profile activation và @Profile ở bài Profiles.
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 --> DHai 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ó:
- Đọc
prefixtừ annotation. - Tra
Environmentlấy tất cả property có prefix đó. - Convert kiểu qua
ConversionService— string"PT30S"→Duration,"true"→boolean, v.v. - Gọi setter (class truyền thống) hoặc bind qua constructor (record).
- 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 --> FXem thêm về Environment và PropertySource chain ở bài Environment & PropertySource.
6. Vì sao ưu tiên @ConfigurationProperties hơn @Value
| Khía cạnh | @Value | @ConfigurationProperties |
|---|---|---|
| Type-safe | Chỉ string/primitive | Nested object, List, Map, Duration, enum |
| Validation | Không | Jakarta Validation (@NotBlank, @Min, @Max) |
| Fail-fast | Không (NPE runtime) | Có — BindValidationException tại startup |
| IDE autocomplete | Không | Có (qua spring-boot-configuration-processor) |
| Relax binding | Không | Có — kebab/camel/UPPER_SNAKE tự map |
| Refactor | String literal khó rename | Java field — IDE rename propagates |
| SpEL | Có | Không |
| Setup overhead | 0 | 1 class + đăng ký |
| Best for | 1-2 prop, SpEL | Mọi config domain-specific |
Quy tắc thực hành:
Mặc định dùng
@ConfigurationProperties. Chỉ dùng@Valuekhi 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ế
Environmentload property từ đâu, thứ tự ưu tiên 17 nguồn, cách@Valueresolve${key}quaPropertySourcechain — đọ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,@Profileannotation, 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:
Environmentinterface,PropertySourcechain bên dưới container, cáchConfigurableEnvironmentđược xây dựng — cơ chế source màConfigurationPropertiesBindingPostProcessortra để 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.ymloverrideapplication.ymlkhi profileprodactive. 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) traEnvironment, convert kiểu quaConversionService, 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.@Valuechỉ khi cần SpEL.
Tự kiểm tra
Q1Vì 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ì?▸
@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 host → BindValidationException → 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.
Q2Cơ 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.▸
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:
- Boot nhận env var:
APP_EMAIL_TLS_ENABLED. - 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→ canonicaltls-enabled. - Match với field
tlsEnabledtrong record (camelCase → canonicaltls-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.
Q3Khi nào nên dùng @Value thay vì @ConfigurationProperties? Cho 1 ví dụ cụ thể cho mỗi trường hợp.▸
@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 —@ConfigurationPropertieskhô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.
Q4Mô 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?▸
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?ConfigurationPropertiesBindingPostProcessor là BeanFactoryPostProcessor — 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:
- Scan tất cả
BeanDefinitiontìm class có annotation@ConfigurationProperties. - Với mỗi class: đọc
prefixtừ annotation, traEnvironment(PropertySource chain đã đượcConfigDataEnvironmentPostProcessorload sẵn) lấy tất cả property có prefix đó. - Convert kiểu qua
ConversionService: string"PT30S"→Duration,"true"→boolean, YAML list →List<String>. - Bind giá trị: setter cho class truyền thống, constructor binding cho record.
- 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.
Q5Record @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 { ... }
▸
@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 đề:
- Không validate: nếu
app.db.urlthiếu trong config, fieldurl = null→ NPE runtime khi code dùng đến, không fail-fast tại startup. Fix: thêm@Validated+ annotation constraint. - Không đăng ký bean:
@ConfigurationPropertieskhông tự register. Thiếu@ConfigurationPropertiesScanhoặc@EnableConfigurationProperties→NoSuchBeanDefinitionExceptionkhi inject. Fix: thêm@ConfigurationPropertiesScanvàoApp. - poolSize không có default:
intprimitive 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 10Pattern 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
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