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

Đọc theo trục thời gian: nguồn được nạp xong rồi mới tới lượt bind, nên thứ tự khai class không ảnh hưởng kết quả. Và chỉ có đúng một bước trong chuỗi này có thể chặn app lại — bước validate, mà bước đó chỉ tồn tại nếu bạn thêm @Validated.
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ó:
- Đọ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. Cơ chế canonical form và ca hỏng im lặng của nó có hình minh hoạ ở bài trước, mục 4.1.
Xem 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
@ConfigurationPropertieskế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ì? - Q2Cơ chế relax binding trong Spring Boot là gì? Giải thích tại sao env var K8s
APP_EMAIL_TLS_ENABLED=truetự động match fieldboolean tlsEnabledtrong record@ConfigurationProperties(prefix="app.email")mà không cần config thêm. - Q3Khi nào nên dùng
@Valuethay vì@ConfigurationProperties? Cho 1 ví dụ cụ thể cho mỗi trường hợp. - 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àBeanFactoryPostProcessorchứ không phảiBeanPostProcessor? - Q5Record
@ConfigurationPropertiessau 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 { ... }
Bài tiếp theo: Profiles — activation & @Profile
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
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