Mọi project do Spring Initializr sinh ra đều có sẵn src/main/resources/application.properties với đúng một dòng, spring.application.name=demo. Series này đã ghi vào file đó ba lần — đổi port, bật lazy initialization, exclude một auto-configuration — mà chưa hề nói gì về chính file đó. Bài này nói về file đó: hai định dạng Spring Boot đọc thực sự chấp nhận những gì, mỗi định dạng âm thầm làm gì mà bạn không hề yêu cầu, và một giá trị đi từ một dòng text vào constructor parameter như thế nào.
Cú pháp trông đơn giản đến mức chẳng cần cả một bài viết, và đó chính là vấn đề. File .properties được decode bằng ISO-8859-1, nên Xin chào đến tay bạn thành Xin chà o. YAML biến 0123 thành 83 và on thành true mà không một lời cảnh báo. Chỉ một @Value thiếu default là đủ khiến application không khởi động được. Tất cả đều đã được chạy thật, và output bên dưới được trích nguyên văn.
![]()
Mọi thứ bên dưới chạy trên OpenJDK 21.0.6 với Spring Boot 4.1.1 (Spring Framework 7.0.9, SnakeYAML 2.6) và Gradle 9.7.1, trên project sinh bởi Spring Initializr với dependencies=web. Mọi giá trị parse ra, kết quả convert và error message đều được copy từ các lần chạy project đó.
Spring Boot đọc application.properties và application.yml từ đâu
Mọi thứ trong src/main/resources được copy vào gốc classpath: build/resources/main khi đang phát triển, BOOT-INF/classes/ bên trong jar. Lúc khởi động, Boot tìm ở đó application.properties, application.yml và application.yaml, parse từng file tìm thấy thành một property source, rồi thêm các property source đó vào Environment. @Value, Environment.getProperty() và mọi thứ khác đọc cấu hình đều đọc từ đó.
Một property source là một map phẳng với key là String, và đây là điều hữu ích nhất trong cả bài: file trông như thế nào không quan trọng, application chỉ thấy một danh sách key nối bằng dấu chấm. Cách nhanh nhất để thấy tận mắt là in map đó ra. Runner dưới đây duyệt Environment và in mọi property source được load từ file cấu hình:
@Component
public class ConfigFileDump implements ApplicationRunner {
private final ConfigurableEnvironment environment;
public ConfigFileDump(ConfigurableEnvironment environment) {
this.environment = environment;
}
@Override
public void run(ApplicationArguments args) {
for (PropertySource<?> source : environment.getPropertySources()) {
if (source instanceof OriginTrackedMapPropertySource file) {
System.out.println(file.getName());
for (String key : file.getPropertyNames()) {
Object value = file.getProperty(key);
System.out.println(" " + key + " = " + value + " (" + value.getClass().getSimpleName() + ")");
}
}
}
}
}Boot dùng cùng một class, OriginTrackedMapPropertySource, cho cả hai định dạng. Mọi bản dump trong phần còn lại của bài đều lấy từ runner này, hoặc từ việc đọc từng key bằng Environment.getProperty().
Cú pháp application.properties
Với file .properties, Boot không dùng java.util.Properties: PropertiesPropertySourceLoader giao file cho loader riêng của Boot là OriginTrackedPropertiesLoader. Vì vậy các quy tắc dưới đây được kiểm tra trực tiếp trên Boot — ghi từng dòng vào application.properties rồi đọc lại — chứ không chép từ Javadoc của Properties.
Dấu phân cách: =, : và khoảng trắng
app.eq=equals sign
app.colon: colon separator
app.space whitespace separator
app.spaced = around the separator
app.indented=leading spaces before the keyThêm ba dấu cách phía sau around the separator, đây là những gì Boot lưu lại:
| Dòng | Key | Giá trị |
|---|---|---|
app.eq=equals sign | app.eq | equals sign |
app.colon: colon separator | app.colon | colon separator |
app.space whitespace separator | app.space | whitespace separator |
app.spaced = around the separator | app.spaced | around the separator cộng ba dấu cách ở cuối — 23 ký tự |
app.indented=leading spaces before the key | app.indented | leading spaces before the key |
Key kết thúc ở ký tự =, : hoặc khoảng trắng đầu tiên không bị escape. Khoảng trắng trước key và ở hai bên dấu phân cách bị bỏ đi; khoảng trắng ở cuối giá trị thì được giữ lại. Một dấu cách thừa sau password hay URL sẽ thành một phần của nó, và không có gì cảnh báo bạn.
Comment, nối dòng và key trùng lặp
# a comment starting with a hash
! a comment starting with an exclamation mark
app.hash=value # not a comment
app.continued=first,\
second,\
third
app.empty=
app.dup=first
app.dup=second- Dòng có ký tự đầu tiên (không tính khoảng trắng) là
#hoặc!là comment và không tạo ra key nào. app.hashlàvalue # not a comment. Dấu#ở giữa dòng là một phần của giá trị — YAML làm ngược lại, như bạn sẽ thấy ở dưới.app.continuedlàfirst,second,third. Dấu gạch chéo ngược ở cuối dòng nối dòng đó với dòng sau, và khoảng trắng đầu dòng sau bị bỏ đi, nên các dòng nối có thể thụt lề tùy ý.app.emptytồn tại và chứa chuỗi rỗng. Nó không phải key bị thiếu, điều này sẽ quan trọng khi nói tới default.app.duplàsecond. Định nghĩa cuối cùng thắng, không một lời báo.
Escape, dấu gạch chéo ngược và đường dẫn Windows
app.tab=a\tb
app.newline=line1\nline2
app.unicode=Xin ch\u00e0o
app.backslash=C:\\temp\\new
app.single-backslash=C:\temp\new
app.key\ with\ spaces=ok
app.equals\=in\:key=ok
app.value-with-equals=a=b
app.value-with-colon=http://localhost:8080| Dòng | Boot lưu lại |
|---|---|
app.tab=a\tb | a, một ký tự TAB, b |
app.newline=line1\nline2 | line1, một ký tự xuống dòng, line2 |
app.unicode=Xin ch\u00e0o | Xin chào |
app.backslash=C:\\temp\\new | C:\temp\new |
app.single-backslash=C:\temp\new | C:, TAB, emp, xuống dòng, ew — 9 ký tự |
app.key\ with\ spaces=ok | key app.key with spaces |
app.equals\=in\:key=ok | key app.equals=in:key |
app.value-with-equals=a=b | giá trị a=b |
app.value-with-colon=http://localhost:8080 | giá trị http://localhost:8080 |
Đường dẫn Windows là thứ gây hại nhiều nhất trong thực tế: \t và \n là escape, nên C:\temp\new được load thành một tab và một ký tự xuống dòng mà không báo lỗi gì. Hãy nhân đôi mọi dấu gạch chéo ngược. Chỉ ký tự phân cách nằm trong key mới cần escape; = và : trong giá trị được giữ nguyên.
List: giá trị phân cách bằng dấu phẩy và key có chỉ số
app.names=alice,bob,carol
app.servers[0]=alpha
app.servers[1]=betaĐây là hai thứ khác nhau. app.names là một key chứa một chuỗi; nó chỉ trở thành list khi có thứ gì đó convert nó, và @Value vào List<String> làm được việc đó, như phần sau sẽ cho thấy. app.servers[0] và app.servers[1] là hai key riêng biệt, và không có key nào tên app.servers: getProperty("app.servers") trả về null. Key có chỉ số quan trọng vì đó chính là thứ mà list trong YAML biến thành.
Chữ tiếng Việt và mặc định ISO-8859-1
Ghi một giá trị tiếng Việt vào file vừa sinh ra. Editor nào hiện nay cũng lưu bằng UTF-8, và file xác nhận điều đó:
app.greeting=Xin chàoapplication.properties: Unicode text, UTF-8 textĐọc lại rồi in chuỗi, độ dài và các code point của nó:
String greeting = environment.getProperty("app.greeting");
System.out.println(greeting + " | length " + greeting.length());
greeting.chars().forEach(c -> System.out.printf("U+%04X ", c));Xin chà o | length 9
U+0058 U+0069 U+006E U+0020 U+0063 U+0068 U+00C3 U+00A0 U+006Fà trong UTF-8 là hai byte, C3 A0. Loader .properties của Boot decode file bằng ISO-8859-1 nếu không được chỉ định charset khác — reader bên trong OriginTrackedPropertiesLoader rơi về StandardCharsets.ISO_8859_1 khi không có charset nào được truyền vào — nên hai byte biến thành hai ký tự: Ã và một dấu cách không ngắt (non-breaking space). @Value("${app.greeting}") cũng inject đúng chín ký tự đó.
Không khâu nào khác gây ra lỗi, và cũng không khâu nào che giấu nó. processResources của Gradle copy file nguyên từng byte — cmp báo file gốc, build/resources/main/application.properties và entry bên trong jar giống hệt nhau — java -jar in ra đúng chuỗi hỏng đó, và console cũng vô can: stdout.encoding là UTF-8 trong lần chạy này. File trông vẫn đúng trong editor vì các byte đều đúng; chỉ có bước decode là sai.
Cùng ba dòng code đó, chạy với từng cách sửa:
| Nội dung trong file | In ra |
|---|---|
app.greeting=Xin chào trong application.properties | Xin chà o | length 9 |
app.greeting=Xin ch\u00e0o trong application.properties | Xin chào | length 8 |
greeting: Xin chào dưới app: trong application.yml | Xin chào | length 8 |
spring.config.import=classpath:greeting.properties[encoding=utf-8], giá trị nằm trong greeting.properties | Xin chào | length 8 |
cùng lệnh import đó nhưng không có [encoding=utf-8] | Xin chà o | length 9 |
Unicode escape chạy được, nhưng không ai đọc hay review nổi. YAML được đọc bằng UTF-8, nên đó là lựa chọn đơn giản nhất cho cấu hình có chữ tiếng Việt. Hint [encoding=utf-8] áp dụng cho file được import; bản thân config import sẽ được nói tới ở một bài sau trong series.
Cú pháp application.yml
YAML viết cùng những cặp key-value đó dưới dạng cây. Thụt lề — chỉ bằng dấu cách — quyết định cái gì thuộc về cái gì. Một key theo sau là dấu hai chấm và một dấu cách thì chứa giá trị; một key chỉ có dấu hai chấm mà không có gì phía sau là cha của các dòng thụt vào bên dưới.
Lồng nhau, list và map
# the same configuration, written as YAML
app:
name: Demo Shop
owner:
name: Hoang
email: hoang@example.com
servers:
- alpha
- beta
regions: [eu-west, ap-southeast]
limits: {requests: 100, burst: 20}File đó có bốn dạng. app và owner là map viết thành các khối thụt lề; servers là block list, mỗi dòng một phần tử - ; regions là flow list trong ngoặc vuông; limits là flow map trong ngoặc nhọn. Dạng block và dạng flow tạo ra cùng một dạng key, như bản dump bên dưới cho thấy. Dòng bắt đầu bằng # là comment.
Chuỗi nhiều dòng, dấu nháy và comment
app:
banner: |
Welcome to Demo Shop
Have a nice day
description: >
A folded string
joins lines with spaces
literal-strip: |-
no trailing
newline
single: 'it''s ${app.name}'
double: "tab\there, accent \u00e0"
plain-hash: value # a commentNhững gì Boot lưu lại, viết dưới dạng string literal của Java để thấy rõ ký tự xuống dòng:
| Key | Giá trị được lưu | Quy tắc |
|---|---|---|
app.banner | "Welcome to Demo Shop\nHave a nice day\n" | | giữ mọi ký tự xuống dòng, kể cả cái cuối cùng |
app.description | "A folded string joins lines with spaces\n" | > gộp các dòng bằng dấu cách và giữ ký tự xuống dòng cuối |
app.literal-strip | "no trailing\nnewline" | |- giữ các ký tự xuống dòng nhưng bỏ cái cuối cùng |
app.single | "it's ${app.name}" | nháy đơn: không có escape, '' là một dấu nháy |
app.double | "tab\there, accent à" | nháy kép xử lý \t, \u00e0 và các escape khác |
app.plain-hash | "value" | # đứng sau một dấu cách bắt đầu comment |
Ký tự xuống dòng cuối của | là một ký tự thật; nếu giá trị là token hay thứ gì đó cần so sánh, hãy dùng |-. Và dấu nháy không ngăn Spring resolve placeholder: getProperty("app.single") trả về it's Demo Shop. Dấu nháy là chuyện của YAML, còn ${...} được Spring resolve sau đó, trên chuỗi đã parse xong.
YAML được làm phẳng thành đúng những key đó
Runner dump chạy với file YAML ở ví dụ lồng nhau — bảy mục đầu tiên:
Config resource 'class path resource [application.yml]' via location 'optional:classpath:/'
app.name = Demo Shop (String)
app.owner.name = Hoang (String)
app.owner.email = hoang@example.com (String)
app.servers[0] = alpha (String)
app.servers[1] = beta (String)
app.regions[0] = eu-west (String)
app.regions[1] = ap-southeast (String)Giờ xóa application.yml và viết cùng dữ liệu đó bằng .properties:
app.name=Demo Shop
app.owner.name=Hoang
app.owner.email=hoang@example.com
app.servers[0]=alpha
app.servers[1]=beta
app.regions[0]=eu-west
app.regions[1]=ap-southeast
app.limits.requests=100
app.limits.burst=20Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'
app.name = Demo Shop (String)
app.owner.name = Hoang (String)
app.owner.email = hoang@example.com (String)
app.servers[0] = alpha (String)
app.servers[1] = beta (String)
app.regions[0] = eu-west (String)
app.regions[1] = ap-southeast (String)Cùng key, cùng thứ tự. getProperty("app.owner.email") trả về hoang@example.com dù đọc từ file nào, getProperty("app.limits.burst") trả về 20 với cả hai, và getProperty("app.servers") trả về null với cả hai — một list không bao giờ là một key riêng; chỉ các phần tử của nó mới là key. Ngoài bảy dòng ở trên, bản dump đầy đủ của hai file chỉ khác nhau một chi tiết: 100 của YAML được lưu thành Integer, 100 của .properties thành String, và khác biệt đó biến mất ngay khi giá trị được đọc ra dưới dạng chuỗi.

Các bẫy YAML làm dừng startup hoặc âm thầm đổi giá trị
Lỗi YAML có hai loại. Loại ồn ào làm application dừng ngay lúc khởi động kèm một message của SnakeYAML. Loại im lặng thì parse bình thường nhưng đưa cho bạn một giá trị khác với cái bạn đã viết.
Tab, dấu hai chấm không quote và key trùng làm application dừng
Thụt lề một dòng bằng tab:
14:44:47.209 [main] ERROR org.springframework.boot.SpringApplication -- Application run failed
while scanning for the next token
found character '\t(TAB)' that cannot start any token. (Do not use \t(TAB) for indentation)
in 'reader', line 2, column 1:
name: Demo Shop
^Viết dấu hai chấm kèm dấu cách bên trong một giá trị không có dấu nháy:
app:
message: Note: read this first14:44:48.005 [main] ERROR org.springframework.boot.SpringApplication -- Application run failed
mapping values are not allowed here
in 'reader', line 2, column 16:
message: Note: read this first
^Dấu hai chấm không có dấu cách phía sau thì vô hại, đó là lý do url: http://localhost:8080 chạy được mà không cần nháy. Hãy đặt giá trị trong dấu nháy — message: "Note: read this first" được load thành Note: read this first — mỗi khi nó chứa : hoặc #.
Khai báo cùng một key hai lần trong một map:
app:
name: Demo Shop
name: Another Shopwhile constructing a mapping
in 'reader', line 2, column 3:
name: Demo Shop
^
found duplicate key name
in 'reader', line 3, column 3:
name: Another Shop
^Boot cấu hình SnakeYAML để từ chối key trùng. SnakeYAML 2.6 thuần với option mặc định chỉ log WARNING: duplicate keys found rồi chạy tiếp, còn file .properties giữ giá trị cuối mà không nói gì. Lỗi y hệt cũng xuất hiện khi hai key khác nhau trong file nhưng bằng nhau sau khi resolve type: on: và yes: trong cùng một map đều thành key boolean true và thất bại với found duplicate key true.
Giá trị âm thầm đổi type
File này parse không có lỗi nào:
app:
feature-enabled: on
legacy-mode: off
confirm-orders: yes
country: NO
pin: 0123
release: 1.10
backup-time: 12:30
launch-date: 2026-10-10
theme-color: #fff
owner: ~
pin-quoted: "0123"
release-quoted: "1.10"Config resource 'class path resource [application.yml]' via location 'optional:classpath:/'
app.feature-enabled = true (Boolean)
app.legacy-mode = false (Boolean)
app.confirm-orders = true (Boolean)
app.country = false (Boolean)
app.pin = 83 (Integer)
app.release = 1.1 (Double)
app.backup-time = 750 (Integer)
app.launch-date = 2026-10-10 (String)
app.theme-color = (String)
app.owner = (String)
app.pin-quoted = 0123 (String)
app.release-quoted = 1.10 (String)Bốn giá trị boolean chẳng ai yêu cầu, một mã PIN mất số 0 ở đầu và thành 83, một bản release giờ là 1.1, giờ backup giờ là số nguyên 750, và một mã màu giờ là chuỗi rỗng. Chỉ những giá trị có dấu nháy là còn nguyên như đã viết.
Dưới đây là mọi giá trị đã thử, với type mà SnakeYAML 2.6 tạo ra và giá trị Environment.getProperty trả về trong Boot 4.1.1. Cột cuối là cùng giá trị đó khi parse bằng một parser YAML 1.2 — package JavaScript yaml 2.8.3 ở chế độ mặc định YAML 1.2 core schema — vì phần lớn lời khuyên về những cái bẫy này được viết cho một phiên bản spec rồi đem áp dụng cho phiên bản kia:
| Giá trị không quote | Type trong SnakeYAML 2.6 | getProperty của Boot | YAML 1.2 core schema |
|---|---|---|---|
on | Boolean | true | chuỗi on |
off | Boolean | false | chuỗi off |
yes | Boolean | true | chuỗi yes |
no, NO | Boolean | false | chuỗi no, NO |
True | Boolean | true | boolean true |
y | String | y | chuỗi y |
0123 | Integer, đọc theo hệ bát phân | 83 | số 123 |
0189 | String, không phải số bát phân hợp lệ | 0189 | số 189 |
0o14 | String | 0o14 | số 12 |
0x1F | Integer | 31 | số 31 |
1_000 | Integer | 1000 | chuỗi 1_000 |
1.10 | Double | 1.1 | số 1.1 |
1.2.3 | String | 1.2.3 | chuỗi 1.2.3 |
1e3 | Double | 1000.0 | số 1000 |
12:30 | Integer, cơ số 60 | 750 | chuỗi 12:30 |
1:30:00 | Integer, cơ số 60 | 5400 | chuỗi 1:30:00 |
2026-10-10 | Date với SnakeYAML thuần, String trong Boot | 2026-10-10 | chuỗi 2026-10-10 |
null, ~ hoặc để trống | null | chuỗi rỗng | null |
#fff | là comment, nên thành null | chuỗi rỗng | null |
"0123", "on" | String | 0123, on | chuỗi |
Ba kết luận từ bảng trên:
- SnakeYAML 2.6 là parser YAML 1.1, nên mọi application Spring Boot đọc
application.ymlđều theo quy tắc YAML 1.1. Những thay đổi của YAML 1.2 —on,yes,nolà chuỗi bình thường, không còn số cơ số 60 — không áp dụng ở đây. Boot thêm hai thay đổi của riêng nó: ngày tháng vẫn là chuỗi, vì loader của Boot bỏ timestamp resolver của SnakeYAML (OriginTrackedYamlLoader.NoTimestampResolver), vànullthành chuỗi rỗng. - Một phần lời truyền miệng sai theo chiều ngược lại.
ythuộc type boolean của YAML 1.1, vàyaml2.8.3 ở chế độ 1.1 đúng là biến nó thànhtrue, nhưng SnakeYAML 2.6 vẫn đểylà chuỗi. Và YAML 1.2 cũng không cứu bạn khỏi mọi thứ: ở đó0123vẫn mất số 0, còn1.10vẫn là1.1. - Thiệt hại rơi vào chuỗi, không phải boolean. Đọc
onvào mộtbooleanthì định dạng nào cũng chotrue—app.flag=ontrong.propertiescũng được convert thànhtrue. Đọc cùng key đó vàoStringthì YAML cho"true"còn.propertiescho"on". Mã quốc gia, mã PIN, số phiên bản và giờ trong ngày đều là chuỗi, và chính chúng là những giá trị bị hỏng.
Key cũng bị resolve type. Trong file thử nghiệm, on: và 0123: nằm dưới map trap.keys được lưu thành key trap.keys[true] và trap.keys[83].
⚠️ Hãy đặt trong dấu nháy mọi giá trị YAML mang nghĩa văn bản mà có thể bị nhầm thành boolean, số, giờ hoặc null:
"NO","0123","1.10","12:30","#fff". Quote một giá trị thực sự là số cũng chẳng mất gì, vì@Valuevẫn convert chuỗi"8091"sangintnhư thường.
Có cả application.properties và application.yml thì file nào thắng?
Cả hai file đều được load, và với key có ở cả hai, application.properties thắng. Hai file đặt cạnh nhau trong src/main/resources:
| Key | Trong .properties | Trong .yml | getProperty trả về |
|---|---|---|---|
app.source | from application.properties | from application.yml | from application.properties |
app.only-in-properties | p | — | p |
app.only-in-yaml | — | y | y |
Hai file được gộp theo từng key chứ không phải chọn nguyên một file: key chỉ có trong một file vẫn được đọc từ file đó. Cấu hình còn có thể đến từ những nguồn nào khác, và thứ tự đầy đủ giữa tất cả các nguồn đó, là chủ đề của một bài sau; với hai file trong cùng một thư mục, đây là quy tắc duy nhất bạn cần.
Placeholder bên trong file cấu hình
Một giá trị có thể trỏ tới key khác bằng ${...}. Property source lưu nguyên văn text như đã viết, và placeholder được resolve mỗi lần giá trị được đọc.
app.host=localhost
app.port=8091
app.url=http://${app.host}:${app.port}
app.timeout=${app.connect-timeout:30s}
app.instance-id=${random.uuid}
app.shard=${random.int(1,10)}| Key | Lưu trong property source | getProperty trả về |
|---|---|---|
app.url | http://${app.host}:${app.port} | http://localhost:8091 |
app.timeout | ${app.connect-timeout:30s} | 30s — không có app.connect-timeout, nên phần text sau dấu : đầu tiên được dùng |
app.instance-id | ${random.uuid} | ca97c7cf-f034-4bde-8066-ac2bba99a24a, rồi d39d8360-189f-4186-a351-02cd34018e29 ở lần gọi sau |
app.shard | ${random.int(1,10)} | 6, 7 và 3 qua ba lần gọi |
Ba chi tiết dễ hiểu sai:
${random.*}được tính lại mỗi lần đọc. Hai lần gọigetProperty("app.instance-id")trả về hai UUID khác nhau, và hai field cùng gắn@Value("${app.instance-id}")cũng nhận hai UUID khác nhau. Nếu instance id phải giữ nguyên suốt vòng đời của process, hãy đọc một lần rồi giữ trong một bean.random.int(1,10)không bao gồm cận trên. 100.000 lần đọc cho ra đúng các giá trị từ 1 đến 9.- Key thiếu bên trong một giá trị chỉ gây lỗi khi giá trị đó được đọc, không phải lúc khởi động. Với
app.broken-url=http://${app.nope}/x, application vẫn khởi động bình thường, và lần gọigetProperty("app.broken-url")đầu tiên sẽ throw:
org.springframework.util.PlaceholderResolutionException: Could not resolve placeholder 'app.nope' in value "http://${app.nope}/x"Placeholder hoạt động y hệt trong YAML, nơi url: http://${app.host}:${app.port} không cần dấu nháy. Để giữ nguyên văn ${ trong giá trị, đặt một dấu gạch chéo ngược trước dấu đô la — cú pháp placeholder của Spring dùng \ làm ký tự escape. Parser .properties tự nuốt một dấu gạch chéo ngược trước khi Spring nhìn thấy giá trị, nên ở đó cần hai:
| File | Cách viết | getProperty trả về |
|---|---|---|
.properties | app.dollar=\${literal} | throw Could not resolve placeholder 'literal' in value "${literal}" |
.properties | app.escaped-placeholder=\\${literal} | ${literal} |
.yml, không nháy hoặc nháy đơn | plain: \${not.a.placeholder} | ${not.a.placeholder} |
.yml, nháy kép | double: "\\${not.a.placeholder}" | ${not.a.placeholder} |
Đọc property bằng @Value
@Value inject một giá trị cấu hình đã được resolve vào bean trong lúc bean được tạo. Nó đặt được trên field, setter hoặc constructor parameter:
@Component
public class FieldStyle {
@Value("${app.name}")
private String name;
}@Component
public class SetterStyle {
private String name;
@Value("${app.name}")
public void setName(String name) {
this.name = name;
}
}Cả hai đều in Demo Shop. Hãy ưu tiên constructor, với cùng lý do như mọi dependency khác: field có thể là final, và test có thể truyền giá trị vào trực tiếp. Dưới đây là một constructor nhận mỗi type phổ biến một giá trị, với cấu hình này:
app.name=Demo Shop
app.host=localhost
app.port=8091
app.url=http://${app.host}:${app.port}
app.secure=true
app.names=alice,bob,carol@Component
public class AppSettings {
private final String url;
private final int port;
private final boolean secure;
private final Duration timeout;
private final List<String> names;
private final String[] nameArray;
private final int[] retryDelays;
public AppSettings(@Value("${app.url}") String url,
@Value("${app.port}") int port,
@Value("${app.secure:false}") boolean secure,
@Value("${app.timeout:30s}") Duration timeout,
@Value("${app.names}") List<String> names,
@Value("${app.names}") String[] nameArray,
@Value("${app.retry-delays:100,200,400}") int[] retryDelays) {
this.url = url;
this.port = port;
this.secure = secure;
this.timeout = timeout;
this.names = names;
this.nameArray = nameArray;
this.retryDelays = retryDelays;
}
}In ra từ bean sau khi application đã khởi động:
url = http://localhost:8091
port = 8091 (int)
secure = true (boolean)
timeout = PT30S (Duration)
names = [alice, bob, carol] (ArrayList, size 3)
nameArray = [alice, bob, carol] (length 3)
retryDelays = [100, 200, 400] (int[])
Mọi @Value đều đi qua cùng các bước, theo thứ tự mà DefaultListableBeanFactory.doResolveDependency chạy trong Spring Framework 7.0.9. Chuỗi trong annotation được đọc ra; placeholder được resolve dựa trên Environment, rơi về phần default sau dấu : đầu tiên khi thiếu key; biểu thức #{...} nếu có được tính; và chuỗi kết quả được convert sang type của parameter. Các phần tiếp theo đi qua lần lượt từng bước.
Giá trị mặc định với ${key:default}
Default là mọi thứ nằm sau dấu hai chấm đầu tiên bên trong ngoặc, và chỉ được dùng khi key không tồn tại. @Value("${app.timeout:30s}") Duration timeout inject PT30S khi file không có app.timeout, và PT45S sau khi thêm app.timeout=45s.
Vì chỉ dấu hai chấm đầu tiên tách key khỏi default, default có thể chứa dấu hai chấm của riêng nó, và có thể là một placeholder khác:
@Value(...) | Được inject |
|---|---|
"${app.fallback-url:http://localhost:8080/api}" | http://localhost:8080/api |
"${app.primary:${app.secondary:nested-default}}" | nested-default |
"app.name" | app.name — không có ${} thì đó là chuỗi nguyên văn, không phải tra key |
Dòng cuối compile được, khởi động được và inject chính tên của key. Đó là lỗi @Value dễ mắc nhất và cũng khó nhận ra nhất.
Thiếu key và không có default thì chuyện gì xảy ra?
@Component
public class ApiClient {
private final String apiKey;
public ApiClient(@Value("${app.api-key}") String apiKey) {
this.apiKey = apiKey;
}
}Không có app.api-key ở đâu cả, application không khởi động được:
2026-09-11T14:45:08.369+07:00 WARN 47311 --- [demo] [ main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'apiClient' defined in file [/…/demo/build/classes/java/main/com/example/demo/ApiClient.class]: Unexpected exception during bean creation
2026-09-11T14:45:08.370+07:00 INFO 47311 --- [demo] [ main] o.apache.catalina.core.StandardService : Stopping service [Tomcat]
2026-09-11T14:45:08.374+07:00 INFO 47311 --- [demo] [ main] .s.b.a.l.ConditionEvaluationReportLogger :
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2026-09-11T14:45:08.378+07:00 ERROR 47311 --- [demo] [ main] o.s.boot.SpringApplication : Application run failed
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'apiClient' defined in file [/…/demo/build/classes/java/main/com/example/demo/ApiClient.class]: Unexpected exception during bean creation
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:538) ~[spring-beans-7.0.9.jar:7.0.9]
...
Caused by: org.springframework.util.PlaceholderResolutionException: Could not resolve placeholder 'app.api-key' in value "${app.api-key}"
at org.springframework.util.PlaceholderResolutionException.withValue(PlaceholderResolutionException.java:81) ~[spring-core-7.0.9.jar:7.0.9]
at org.springframework.util.PlaceholderParser$ParsedValue.resolve(PlaceholderParser.java:296) ~[spring-core-7.0.9.jar:7.0.9]
at org.springframework.util.PlaceholderParser.replacePlaceholders(PlaceholderParser.java:129) ~[spring-core-7.0.9.jar:7.0.9]
at org.springframework.util.PropertyPlaceholderHelper.replacePlaceholders(PropertyPlaceholderHelper.java:96) ~[spring-core-7.0.9.jar:7.0.9]
at org.springframework.core.env.AbstractPropertyResolver.doResolvePlaceholders(AbstractPropertyResolver.java:286) ~[spring-core-7.0.9.jar:7.0.9]
at org.springframework.core.env.AbstractPropertyResolver.resolveRequiredPlaceholders(AbstractPropertyResolver.java:257) ~[spring-core-7.0.9.jar:7.0.9]
at org.springframework.context.support.PropertySourcesPlaceholderConfigurer.lambda$processProperties$0(PropertySourcesPlaceholderConfigurer.java:184) ~[spring-context-7.0.9.jar:7.0.9]
at org.springframework.beans.factory.support.AbstractBeanFactory.resolveEmbeddedValue(AbstractBeanFactory.java:959) ~[spring-beans-7.0.9.jar:7.0.9]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1679) ~[spring-beans-7.0.9.jar:7.0.9]
...Không có failure analyzer nào xử lý exception này, nên sẽ không có khung "APPLICATION FAILED TO START" kèm description và action — hãy đọc dòng Caused by: cuối cùng. Các frame bên dưới chính là bước tra key trong sơ đồ: doResolveDependency gọi resolveEmbeddedValue, đi qua PropertySourcesPlaceholderConfigurer vào resolveRequiredPlaceholders, không tìm thấy giá trị và cũng không có default để dùng. Tomcat bị dừng và process thoát.
Cũng exception đó xuất hiện với @Value("${app.servers}") List<String> khi app.servers là một list YAML — Could not resolve placeholder 'app.servers' in value "${app.servers}" — vì chỉ có app.servers[0] và app.servers[1] tồn tại. @Value("${app.servers[0]}") thì chạy được.
Default rỗng ${key:}
Dấu hai chấm không có gì phía sau biến giá trị thành tùy chọn, với kết quả tùy vào type đích:
| Type đích | @Value("${missing.key:}") inject |
|---|---|
String | chuỗi rỗng |
List<String> | một ArrayList rỗng |
String[] | một array rỗng |
Integer, Boolean, Duration | null |
int | không inject được — startup thất bại với Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: "" |
Default rỗng trên primitive type là một lỗi startup đội lốt giá trị tùy chọn. Hãy dùng wrapper type, hoặc một default thật sự.
Type conversion: int, boolean, Duration, List và array
Mọi chuỗi mà @Value tạo ra đều được convert bởi conversion service của bean factory, và trong application Boot đó là org.springframework.boot.convert.ApplicationConversionService. Từng dòng dưới đây đều được inject vào một constructor parameter hoặc một field:
| Giá trị trong file | Type đích | Được inject |
|---|---|---|
8091 | int | 8091 |
true | boolean | true |
on | boolean | true; inject vào String thì vẫn là on |
30s | Duration | PT30S |
30 | Duration | PT0.03S — số không kèm đơn vị nghĩa là millisecond |
alice,bob,carol | List<String> | [alice, bob, carol], một ArrayList có 3 phần tử |
alice, bob , carol | List<String> | [alice, bob, carol] — từng phần tử được trim |
alice,bob,carol | String[] | [alice, bob, carol], length 3 |
100,200,400 | int[] | [100, 200, 400] |
-5 | int | -5 |
eighty | int | startup thất bại, xem bên dưới |
Giá trị không convert được sẽ làm startup thất bại ngay tại bean yêu cầu nó. Đây là app.port=eighty với AppSettings:
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'appSettings' defined in file [/…/demo/build/classes/java/main/com/example/demo/AppSettings.class]: Unsatisfied dependency expressed through constructor parameter 1: Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: "eighty"
...
Caused by: org.springframework.beans.TypeMismatchException: Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: "eighty"
...
Caused by: java.lang.NumberFormatException: For input string: "eighty"constructor parameter 1 đếm từ 0: đó là port, parameter thứ hai.
${...} và #{...}: property placeholder và SpEL
${...} là property placeholder. Phần bên trong là một key, kèm default nếu có, được tra trong Environment, và placeholder bị thay bằng giá trị tìm được. Đó là thay text, không hơn. #{...} là SpEL expression — Spring Expression Language. Phần bên trong được parse như code rồi tính: toán tử, gọi method, type reference bằng T(...), các variable systemProperties và environment.
Với app.port=8091, app.host=localhost và app.names=alice,bob,carol:
@Value(...) | Được inject |
|---|---|
"${app.port} + 1" | 8091 + 1, một String |
"#{${app.port} + 1}" | 8092 |
"#{'${app.host}'.toUpperCase()}" | LOCALHOST |
"#{systemProperties['java.version']}" | 21.0.6 |
"#{environment['app.host']}" | localhost |
"#{environment['app.missing'] ?: 'elvis-default'}" | elvis-default |
"#{T(java.lang.Math).max(${app.min-workers:2}, 4)}" | 4 |
"#{'${app.names}'.split(',')}" vào List<String> | [alice, bob, carol] |
"#{'${app.names}'.split(',').length}" vào int | 3 |
Dòng đầu tiên là dòng cần nhớ: ${app.port} + 1 không làm phép cộng nào; nó tạo ra chuỗi 8091 + 1. Phép tính, gọi method và giá trị tính từ giá trị khác đều cần #{...}, thường có một ${...} bên trong.
Cái nào chạy trước?
Luôn là placeholder. Trong DefaultListableBeanFactory.doResolveDependency, lời gọi resolveEmbeddedValue đứng trước evaluateBeanDefinitionString, và ba lần chạy cho thấy hệ quả mà không cần đọc bytecode.
Placeholder viết ra expression. Với app.expr=6*7, @Value("#{${app.expr}}") int inject 42. Placeholder đã đặt 6*7 vào expression trước khi SpEL parse nó; riêng @Value("${app.expr}") String thì chỉ là 6*7.
SpEL chỉ thấy text đã được thay. Bỏ dấu nháy quanh placeholder:
public SpelBroken(@Value("#{${app.names}.split(',')}") List<String> names) {Caused by: org.springframework.expression.spel.SpelParseException: Expression [alice,bob,carol.split(',')] @5: EL1041E: After parsing a valid expression, there is still more data in the expression: 'comma(,)'Expression trong lỗi là alice,bob,carol.split(',') — ${app.names} đã được thay từ trước. SpEL parse alice thành một expression hoàn chỉnh rồi gặp dấu phẩy. Chính dấu nháy đơn trong #{'${app.names}'.split(',')} biến text đã thay thành một string literal của SpEL.
Giá trị trong file cũng bị tính. Với app.computed=#{2*21}, @Value("${app.computed}") int inject 42: placeholder resolve ra #{2*21}, rồi bước expression tính nó. environment.getProperty("app.computed") trả về nguyên văn text #{2*21}. Vậy mọi thứ đi tới @Value qua placeholder đều có thể chạy như một expression, và đó là một lý do để để ý giá trị cấu hình đến từ đâu.
Từ tất cả những điều trên suy ra một khác biệt về hành vi. app.names-spaced=alice, bob , carol inject bằng @Value("${app.names-spaced}") List<String> cho [alice, bob, carol], vì conversion service trim từng phần tử. Qua #{'${app.names-spaced}'.split(',')} thì cho [alice, bob , carol], và phần tử thứ hai là bob còn nguyên hai dấu cách — String.split không trim gì cả.

Đọc property qua Environment
@Value cố định key từ lúc compile. Khi key chỉ được biết lúc chạy, hoặc code cần rẽ nhánh theo việc key có tồn tại hay không, hãy inject Environment và hỏi trực tiếp. Trong application Boot, object được inject là một org.springframework.boot.ApplicationEnvironment.
@Component
public class EnvironmentDemo implements ApplicationRunner {
private final Environment environment;
public EnvironmentDemo(Environment environment) {
this.environment = environment;
}
@Override
public void run(ApplicationArguments args) {
System.out.println(environment.getProperty("app.host"));
System.out.println(environment.getProperty("app.api-key"));
System.out.println(environment.getProperty("app.api-key", "not-set"));
System.out.println(environment.getProperty("app.port", Integer.class));
System.out.println(environment.getProperty("app.max-connections", Integer.class, 0));
System.out.println(environment.getProperty("app.timeout", Duration.class, Duration.ofSeconds(30)));
System.out.println(environment.getProperty("app.url"));
System.out.println(environment.containsProperty("app.host"));
System.out.println(environment.getRequiredProperty("app.api-key"));
}
}localhost
null
not-set
8091
0
PT30S
http://localhost:8091
true
2026-09-11T15:06:33.604+07:00 INFO 68821 --- [demo] [ main] .s.b.a.l.ConditionEvaluationReportLogger :
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2026-09-11T15:06:33.607+07:00 ERROR 68821 --- [demo] [ main] o.s.boot.SpringApplication : Application run failed
java.lang.IllegalStateException: Required key 'app.api-key' not found| Lời gọi | Trả về |
|---|---|
getProperty("app.host") | giá trị đã resolve, dạng String |
getProperty("app.api-key") | null khi thiếu key — không bao giờ throw chỉ vì key không tồn tại |
getProperty("app.api-key", "not-set") | default khi thiếu key |
getProperty("app.port", Integer.class) | giá trị đã convert sang type yêu cầu |
getProperty("app.max-connections", Integer.class, 0) | default có type khi thiếu key |
getProperty("app.timeout", Duration.class, Duration.ofSeconds(30)) | một Duration; các conversion giống @Value đều dùng được |
getProperty("app.url") | http://localhost:8091 — placeholder được resolve |
containsProperty("app.host") | true khi có property source nào đó chứa key |
getRequiredProperty("app.api-key") | throw IllegalStateException: Required key 'app.api-key' not found |
Ba trường hợp biên đáng biết:
- Key tồn tại nhưng không convert được thì throw.
getProperty("app.bad-port", Integer.class)vớiapp.bad-port=eightycho raConversionFailedException: Failed to convert from type [java.lang.String] to type [java.lang.Integer] for value [eighty]. Environmentresolve${...}nhưng không bao giờ tính#{...}:getProperty("app.computed")trả về#{2*21}.resolvePlaceholders("${app.host}:${app.port} ${app.api-key}")trả vềlocalhost:8091 ${app.api-key}, để nguyên placeholder không biết; cònresolveRequiredPlaceholdersthì throwPlaceholderResolutionExceptioncho trường hợp đó.
Giới hạn của @Value
@Value là công cụ đúng cho một hai giá trị. AppSettings ở trên cho thấy chỗ nó bắt đầu không còn phù hợp:
- Không có validation.
app.port=-5inject-5mà không phàn nàn gì, vàapp.urlvui vẻ trở thànhhttp://localhost:-5. Mọi kiểm tra khoảng giá trị đều là code bạn tự viết. - Không gom nhóm được. Bảy setting liên quan là bảy annotation và một constructor bảy parameter, và mọi class khác cần chúng đều phải lặp lại cả bảy.
- Key là chuỗi rải rác khắp các class. Đổi tên
app.timeouttrong file thì compiler không có gì để nói. Gõ sai còn tệ hơn khi có default:${app.timout:30s}khởi động êm và dùng30smãi mãi. - Không có metadata cho IDE. Không có gì mô tả các key
app.*, nên IDE không thể gợi ý chúng trongapplication.propertieshay đánh dấu key nào không có code nào đọc. - Không có giá trị có cấu trúc. Một list YAML dưới
app.serverskhông inject nguyên khối được, như đã thấy ở trên.
@ConfigurationProperties giải quyết cả năm điểm này, và đó là chủ đề của bài tiếp theo.
Nên dùng .properties hay .yml?
application.properties | application.yml | |
|---|---|---|
| Cấu trúc | mỗi dòng một key đầy đủ nối bằng dấu chấm | lồng nhau bằng thụt lề, chỉ dùng dấu cách |
| Dấu phân cách | =, : hoặc khoảng trắng | dấu hai chấm kèm một dấu cách |
| Comment | # hoặc ! ở đầu dòng; # giữa dòng là một phần của giá trị | # sau khoảng trắng, ở bất kỳ đâu trên dòng |
| List | a,b,c trong một key, hoặc key[0]= và key[1]= | khối - item hoặc [a, b] |
| Chuỗi nhiều dòng | escape \n và nối dòng bằng \ | |, > và |- |
| Type | mọi giá trị là String cho tới khi được convert | type theo YAML 1.1: on → true, 0123 → 83, 12:30 → 750 nếu không quote |
| Chữ không phải ASCII | decode bằng ISO-8859-1, nên Xin chào → Xin chà o nếu không escape | đọc bằng UTF-8 |
| Key trùng | cái cuối cùng thắng, không báo gì | startup thất bại với found duplicate key |
| Khi gõ sai | một dòng sai thường vẫn là dòng hợp lệ | một tab hay một : lạc chỗ làm dừng startup |
| Tìm kiếm | grep app.owner.email tìm thấy key | key bị tách ra nhiều dòng, nên cùng lệnh grep không tìm thấy gì |
| Cùng key ở cả hai file | thắng | thua |
Chọn .properties cho cấu hình nhỏ và phẳng, cho file được script chỉnh sửa, và khi muốn mọi key đều grep được. Chọn .yml cho cấu trúc phân cấp sâu và list, và cho mọi cấu hình có chữ tiếng Việt. Dù chọn cái nào, mỗi project hãy dùng một loại: hai file vẫn chạy chung được, nhưng một key khai báo ở cả hai nơi với một bên thắng âm thầm thì rất khó lần ra.
FAQ
Spring Boot có đọc application.properties bằng UTF-8 không?
Không. Trong Spring Boot 4.1.1, file cấu hình .properties được decode bằng ISO-8859-1 nếu không chỉ định encoding, nên chữ tiếng Việt UTF-8 như Xin chào bị đọc thành Xin chà o. Hãy dùng escape dạng \u00e0, chuyển các giá trị sang application.yml (được đọc bằng UTF-8), hoặc import một file riêng kèm [encoding=utf-8].
Dùng application.properties và application.yml cùng lúc được không?
Được. Cả hai đều được load và gộp theo từng key. Khi cùng một key có ở cả hai file trong cùng một vị trí, giá trị từ application.properties được dùng.
Vì sao field @Value của tôi bị null?
Hai nguyên nhân chiếm gần hết các trường hợp. Field là static: Spring bỏ qua nó và log đúng một dòng INFO.
2026-09-11T14:47:25.774+07:00 INFO 48742 --- [demo] [ main] f.a.AutowiredAnnotationBeanPostProcessor : Autowired annotation is not supported on static fields: private static java.lang.String com.example.demo.StaticProbe.nameHoặc object được tạo bằng new thay vì lấy từ container: new FieldStyle().name() trả về null, vì không có gì xử lý annotation của nó. Thiếu key thì không phải nguyên nhân — trường hợp đó làm startup thất bại luôn.
Làm sao inject một list từ application.yml bằng @Value?
List trong YAML được lưu thành các key có chỉ số, nên @Value("${app.servers}") thất bại với Could not resolve placeholder 'app.servers', còn @Value("${app.servers[0]}") thì chạy được. Muốn inject cả list bằng @Value, hãy viết nó thành một chuỗi phân cách bằng dấu phẩy — names: alice,bob,carol — và inject vào List<String>, kết quả là [alice, bob, carol]. Bind một list YAML thật sự vào List là việc của @ConfigurationProperties.
Vì sao 0123 biến thành 83 trong application.yml?
SnakeYAML 2.6 theo YAML 1.1, trong đó số không quote có số 0 ở đầu là số bát phân, nên 0123 là 83. Boot lưu Integer đó, và đọc ra dưới dạng chuỗi thì được 83. Hãy viết "0123". Cùng những quy tắc đó biến on thành true, 1.10 thành 1.1 và 12:30 thành 750.
${} và #{} trong @Value khác nhau thế nào?
${key} tra một key trong Environment và thay giá trị của nó vào dưới dạng text. #{expression} tính một SpEL expression. Hai cú pháp kết hợp được, như #{'${app.names}'.split(',')}, và placeholder luôn được resolve trước khi expression được tính.
Làm sao viết ${ nguyên văn trong giá trị property?
Escape dấu đô la bằng một dấu gạch chéo ngược. Trong .properties, parser của file bỏ đi một dấu gạch chéo ngược trước, nên hãy viết \\${literal}; trong YAML, \${literal} không nháy hoặc nháy đơn là đủ. Cả hai đều đọc ra ${literal}.
Kết luận
Cả hai file đều đi về cùng một chỗ: một map phẳng gồm các key String nối bằng dấu chấm trong Environment, với application.properties thắng khi một key có mặt ở cả hai. Chúng khác nhau ở chỗ làm gì với text của bạn trên đường vào. .properties decode bằng ISO-8859-1, giữ dấu cách ở cuối và coi dấu gạch chéo ngược là escape; YAML đọc UTF-8 nhưng áp type theo YAML 1.1, nên NO, 0123, 1.10 và 12:30 cần dấu nháy. Placeholder được resolve mỗi lần đọc, kể cả ${random.*}. @Value đọc key, resolve placeholder, tính SpEL, convert kết quả, và làm startup thất bại khi key không có giá trị lẫn default.
Như vậy là đủ để đọc vài giá trị. Nhưng chưa đủ cho một nhóm setting liên quan cần được validate, và đó là chỗ bài tiếp theo bắt đầu: @ConfigurationProperties — cấu hình type-safe kết hợp validation.