Record làm DTO — nơi record hợp và nơi record gãy
Record đi qua ranh giới HTTP thế nào: Jackson bind qua constructor chứ không qua setter, @Valid đặt ở đâu, và vì sao lấy record làm JPA entity là sai từ gốc.
TL;DR: Record là lựa chọn mặc định cho DTO ở ranh giới HTTP vì nó bất biến miễn phí: không setter nghĩa là không ai sửa được request/response giữa đường. Jackson (từ 2.12) bind JSON vào record qua canonical constructor chứ không qua setter, và lấy tên field thẳng từ record component nên miễn nhiễm với cái bẫy -parameters vốn hành DTO class thường. Bean Validation đặt annotation ngay trên record component, @Valid vẫn đứng ở tham số controller như DTO thường. Nhưng record không hợp mọi ranh giới: JPA entity cần constructor rỗng, field mutable, class không final để proxy lazy-loading — cả ba điều record đều thiếu.
Một buổi tối, TaskImportService của TaskFlow chạy import hàng loạt từ file CSV — 1.200 dòng task cần tạo cùng lúc. Code nhìn vô hại:
CreateTaskRequest request = new CreateTaskRequest();
for (CsvRow row : rows) {
request.setTitle(row.title());
request.setDescription(row.description());
request.setAssigneeEmail(row.assigneeEmail());
executor.submit(() -> taskService.create(request)); // async, khong doi ket qua
}
request khai báo một lần ngoài vòng lặp rồi tái sử dụng — thói quen phổ biến để đỡ cấp phát object. Vấn đề nằm ở lambda () -> taskService.create(request): nó chụp tham chiếu tới request, không chụp giá trị hiện tại. Worker thread chạy tới dòng CSV thứ 800 rất có thể đang đọc request mà vòng lặp chính đã ghi đè sang dòng 950. Kết quả thật: trong 1.200 task tạo ra, 264 task lưu sai title hoặc sai người được giao — không exception, không log lỗi, chỉ dữ liệu sai lặng lẽ nằm trong database.
Bài này giải thích vì sao record dập tắt cả lớp bug này gần như miễn phí, cơ chế Jackson và Bean Validation vận hành trên record ở ranh giới HTTP, và những ranh giới record không dùng được.
1. Vì sao DTO mutable là quả bom hẹn giờ
Bạn đã biết cú pháp record từ bài Record — data class với 1 dòng: một dòng khai báo, compiler tự sinh canonical constructor, accessor, equals/hashCode/toString. Điều bài đó chưa nói là hệ quả của record ở ranh giới HTTP, nơi object đi qua nhiều tầng trước khi tới database.
Class DTO mutable kiểu cũ giữ một cánh cửa mở: bất kỳ đoạn code nào cầm tham chiếu tới nó đều gọi được setter và đổi giá trị bất cứ lúc nào. Phần lớn thời gian không sao, nhưng chỉ cần một chỗ tái sử dụng tham chiếu qua boundary bất đồng bộ (thread pool, queue, cache), cửa mở đó biến thành bug im lặng như hook ở trên.
Record đóng cánh cửa đó bằng thiết kế ngôn ngữ, không phải kỷ luật lập trình viên: field private final, gán đúng một lần trong canonical constructor, không sinh setter — không đoạn code nào đổi được giá trị sau khi record đã tạo ra.
Class DTO mutable là tờ giấy nháp — ai cầm cũng viết đè được. Record là biên nhận đã đóng dấu — in ra rồi không sửa, muốn khác thì tạo biên nhận mới.
Running example xuyên bài là request/response của TaskFlow khi tạo task mới:
public record CreateTaskRequest(
String title,
String description,
String assigneeEmail
) {}
public record TaskResponse(
Long id,
String title,
TaskStatus status,
String assigneeEmail,
Instant createdAt
) {}
Không constructor rỗng, không setter, không builder — record buộc mọi giá trị có mặt đầy đủ ngay lúc tạo. Đó đúng là hợp đồng DTO cần: dữ liệu tới trọn vẹn từ JSON, ra trọn vẹn thành JSON, không trạng thái lỡ dở ở giữa.
2. Jackson bind JSON vào record bằng cách nào?
Class DTO mutable cần constructor rỗng để Jackson tạo object trước, rồi gọi từng setter đổ giá trị vào sau — hai bước tách rời. Record không có constructor rỗng và không có setter. Từ Jackson 2.12, thư viện nhận diện type record và bind toàn bộ JSON trong một lần gọi canonical constructor, đối tượng ra đời đã đầy đủ và bất biến ngay từ dòng đầu.
flowchart LR
J[JSON body] --> D{Target type}
D -- record --> CC[Canonical constructor - 1 call]
CC --> R[CreateTaskRequest bat bien]
D -- class DTO --> NC[No-arg constructor]
NC --> ST[setTitle, setDescription, setAssigneeEmail]
ST --> M[Object mutable, con ho giua duong]Để khớp key JSON, Jackson cần biết tên từng tham số — title, description, assigneeEmail. Với một DTO class thường, đây là chỗ hay gãy: trình biên dịch mặc định không giữ tên tham số constructor trong bytecode, nên nếu không có cờ -parameters, Jackson chỉ thấy arg0, arg1 và không khớp được key nào.
Record không dính vấn đề đó: mỗi record mang sẵn một danh sách record component kèm tên, javac bắt buộc ghi vào class file cho mọi record — không phải tuỳ chọn bật tắt. Jackson đọc thẳng danh sách đó:
// jackson-databind, RecordUtil.getRecordFieldNames
final RecordComponent[] components = recordType.getRecordComponents();
return Arrays.stream(components).map(RecordComponent::getName).toArray(String[]::new);
Không có dòng nào hỏi tới tên tham số constructor, nên cờ -parameters không ảnh hưởng gì tới việc bind record. Biên dịch record bằng javac trần, không cờ nào cả, Jackson vẫn map đúng field.
Spring Boot Gradle/Maven plugin thật sự tự thêm -parameters vào mọi task biên dịch — nhưng để phục vụ những chỗ khác cần tên tham số của method/constructor thường: constructor binding cho @ConfigurationProperties trên class không phải record, hay suy tên @PathVariable/@RequestParam khi bạn không khai tên tường minh. Đừng gán công đó cho việc record deserialize được — hai cơ chế độc lập, và ghép nhầm chúng sẽ khiến bạn đi sai hướng khi thật sự phải debug một lỗi bind.
3. Bean Validation trên record: annotation đặt ở đâu?
Bạn đã dùng @NotBlank, @Size, @Valid ở bài Jakarta Bean Validation & @Valid cho DTO class thường — annotation trên field, @Valid ở tham số controller. Với record, @Valid giữ nguyên chỗ đứng; điều đổi chỗ là annotation constraint, đặt thẳng trên record component khai trong ngoặc đơn sau tên record:
public record CreateTaskRequest(
@NotBlank @Size(max = 200) String title,
@Size(max = 2000) String description,
@NotBlank @Email String assigneeEmail
) {}
@PostMapping("/api/tasks")
public TaskResponse create(@RequestBody @Valid CreateTaskRequest request) {
return taskService.create(request);
}
Compiler tự copy annotation từ record component sang field, accessor, và tham số canonical constructor ngầm định — đúng nơi Hibernate Validator đọc. Theo tác giả Hibernate Validator, validate cho record xảy ra ngay tại thời điểm khởi tạo: constructor chạy xong không ném exception nghĩa là object đã thoả invariant — không còn khoảng hở giữa "đã tạo" và "chưa validate" như class thường phải gọi validator.validate(obj) riêng một bước.
Cần constraint bắc qua nhiều field (ví dụ so sánh startDate với endDate) thì phải khai canonical constructor tường minh, đủ tham số. Lúc đó annotation trên từng component KHÔNG còn tự copy sang nữa — constraint per-field âm thầm biến mất, không lỗi compile, không cảnh báo. Compact constructor (không khai lại danh sách tham số) không bị ảnh hưởng.
4. Compact constructor chuẩn hoá giá trị trước khi field được gán
Bạn đã biết cú pháp compact constructor — khai public CreateTaskRequest { ... } không lặp lại danh sách tham số — và thân nó chạy trước khi Java gán giá trị vào field. Ranh giới HTTP là đúng chỗ tận dụng thứ tự đó: dữ liệu JSON thường có khoảng trắng thừa, chữ hoa/thường lẫn lộn, cần chuẩn hoá trước khi trở thành bất biến.
public record CreateTaskRequest(
@NotBlank @Size(max = 200) String title,
@Size(max = 2000) String description,
@NotBlank @Email String assigneeEmail
) {
public CreateTaskRequest {
title = title == null ? null : title.strip();
description = description == null ? null : description.strip();
assigneeEmail = assigneeEmail == null ? null : assigneeEmail.strip().toLowerCase();
}
}
Thứ tự thực thi: strip và hạ chữ thường chạy trước, Java gán giá trị đã chuẩn hoá vào field, Bean Validation kiểm tra @NotBlank/@Size trên giá trị đã chuẩn hoá — không phải giá trị thô. Title toàn khoảng trắng (" ") bị strip() biến thành chuỗi rỗng và @NotBlank bắt đúng, thay vì lọt qua vì chuỗi gốc "trông có vẻ" không rỗng.
Khác với chuẩn hoá ở service layer: compact constructor đảm bảo mọi đường tạo CreateTaskRequest — controller, test, import CSV — đều đi qua cùng một invariant, vì không có cách nào tạo record mà bỏ qua constructor.
5. Ranh giới record: chỗ hợp, chỗ gãy
Phần đáng chú ý của bài không phải "record dùng được ở đâu" mà là "record không dùng được ở đâu" — chỗ dev quen record hay lạm dụng nó cho mọi thứ.
flowchart TB
Rec[Record]
Rec --> DTO[DTO request/response HTTP]
Rec --> Proj[JPA projection - class-based / constructor expression]
Rec -.-> Ent[JPA entity]
Rec -.-> Bean[Spring bean can setter injection]
DTO --> OK1[Hop - batbien, khong can proxy]
Proj --> OK2[Hop - chi doc, co san all-args constructor]
Ent --> Gay1[Gay - thieu no-arg ctor, field final, class final]
Bean --> Gay2[Gay - khong co setter de container goi]| Ranh giới | Record dùng được? | Vì sao |
|---|---|---|
| DTO request/response | Có | Bất biến đúng là thứ DTO cần |
JPA projection (class-based, SELECT new ...) | Có | Projection chỉ cần một constructor đủ tham số để JPQL gọi — record có sẵn |
| JPA entity | Không | Xem mục dưới — gãy ở ba điểm cùng lúc |
| Spring bean cần setter injection / giữ state nội bộ thay đổi theo thời gian | Không | Không có setter, field final — container không có chỗ để ghi |
| Kế thừa (base class dùng chung logic) | Không | Record implicit final, không extend lớp nào ngoài java.lang.Record |
5.1 Vì sao JPA entity không thể là record
Ba yêu cầu của entity do JPA/Hibernate quản lý đều đụng đúng bản chất record:
- Constructor rỗng: JPA tạo instance trước, đổ dữ liệu ResultSet vào sau qua reflection — record chỉ có canonical constructor đủ tham số bắt buộc.
- Field mutable: Hibernate set field trực tiếp lúc lazy load hoặc flush dirty-checking — field record
final, chỉ gán một lần. - Class không
final: lazy loading (@ManyToOne) cần Hibernate tạo proxy bọc quanh entity — recordfinalchặn việc đó.
Record vẫn tuyệt vời làm projection — kết quả đọc để hiển thị, không phải entity managed:
public record TaskSummary(Long id, String title, TaskStatus status) {}
public interface TaskRepository extends JpaRepository<TaskEntity, Long> {
@Query("SELECT new com.taskflow.dto.TaskSummary(t.id, t.title, t.status) "
+ "FROM TaskEntity t WHERE t.assigneeEmail = :email")
List<TaskSummary> findSummariesByAssignee(String email);
}
TaskEntity vẫn phải là class thường — mutable, constructor rỗng, không final — đúng thứ TaskSummary không cần.
Pitfall thường gặp
❌ Đổ lỗi cho cờ -parameters khi record không bind được: đây là chẩn đoán sai phổ biến, học từ các bài viết về DTO class thường rồi áp nhầm sang record. Record lấy tên field từ record component nên cờ đó không liên quan; tìm sai chỗ thì bạn sẽ sửa build script cả buổi mà lỗi vẫn còn.
✅ Với record, nghi ba chỗ khác trước: phiên bản Jackson dưới 2.12 (chưa hỗ trợ record), một constructor không phải canonical được đánh @JsonCreator (trường hợp này mới thật sự cần -parameters hoặc @JsonProperty trên từng tham số), hoặc tên field JSON đơn giản là không khớp tên component.
❌ Khai canonical constructor tường minh chỉ để thêm side-effect nhỏ, tưởng constraint trên component vẫn còn tác dụng — nó đã mất, không cảnh báo gì (xem Callout mục 3).
✅ Side-effect đơn giản không liên quan validate thì dùng compact constructor. Chỉ khai constructor tường minh đủ tham số khi thực sự cần constraint cross-field, và tự khai lại constraint từng tham số.
❌ Lấy record làm @Entity cho gọn, tưởng ít code hơn luôn tốt hơn:
@Entity
public record TaskEntity(@Id Long id, String title) {} // khong compile duoc theo cach JPA can
✅ Entity managed phải là class thường. Dùng record cho DTO và projection, giữ entity là class mutable như JPA yêu cầu.
Đào sâu
Spec / reference chính thức:
- Spring Boot Gradle Plugin — Reacting to other plugins — xác nhận plugin tự thêm cờ
-parametersvào mọi taskJavaCompile. - Spring Data JPA Reference — Projections — mục "Class-based Projections (DTOs)" khuyến nghị record cho DTO projection.
- Gunnar Morling — Enforcing Java Record Invariants With Bean Validation — tác giả Hibernate Validator giải thích cơ chế copy annotation và pitfall khi khai constructor tường minh.
Ghi chú: ba nguồn phủ ba mảnh kỹ thuật của bài — compile flag, ORM projection, validation.
Liên hệ các bài khác
- Record — data class với 1 dòng — cú pháp compact constructor, canonical constructor bài này giả định bạn đã nắm.
- Jakarta Bean Validation & @Valid — cơ chế
@Validtrigger validation; bài này chỉ đổi chỗ đặt annotation. - Problem Details RFC 9457 — constraint fail trên record component vẫn map ra
ProblemDetailnhư DTO thường. - Virtual threads trong Boot — bài tiếp theo; record bất biến có lợi thêm khi chạy trên virtual thread.
Tóm tắt
- Record cũng gãy ở hai ranh giới khác: Spring bean cần setter injection, và kế thừa base class — record chỉ extend được
java.lang.Record. - Jackson gọi canonical constructor một lần để tạo record từ JSON, lấy tên field từ record component nên không cần cờ
-parameters— khác hẳn DTO class thường. - Constraint Bean Validation trên record component tự copy sang canonical constructor ngầm định — nhưng constructor tường minh đủ tham số làm mất tự động hoá này.
- Compact constructor chạy trước khi field được gán, đúng chỗ chuẩn hoá dữ liệu — Bean Validation sẽ thấy giá trị đã chuẩn hoá, không phải giá trị thô.
- JPA entity gãy với record ở cả ba điều kiện; projection thì ngược lại — chỉ cần constructor đủ tham số để đọc dữ liệu ra.
Tự kiểm tra
Q1Vì sao lambda async trong hook đọc nhầm dữ liệu, và record giải quyết dứt điểm hay chỉ giảm xác suất?▸
Lambda chụp tham chiếu tới request, không chụp giá trị lúc submit — worker đọc trúng lúc vòng lặp đã ghi đè field khác. Record giải quyết dứt điểm: không setter nghĩa là không còn thao tác nào để gây race.
Q2Một đồng nghiệp biên dịch TaskFlow bằng javac tay, không cờ nào cả, rồi lo rằng Jackson sẽ không bind được CreateTaskRequest. Lo đó đúng hay sai, và vì sao?▸
Sai. CreateTaskRequest là record, mà Jackson lấy tên field qua RecordComponent.getName() — đọc từ danh sách record component javac luôn ghi vào class file cho mọi record, không phụ thuộc cờ biên dịch nào. Bind vẫn đúng. Lo đó chỉ chính đáng nếu DTO là class thường: khi ấy Jackson phải đọc tên tham số constructor, và tên đó chỉ còn trong bytecode khi có cờ -parameters.
Q3Tại sao đặt @NotBlank trên record component là đủ, và trường hợp nào cách này thất bại?▸
Compiler tự copy annotation từ component sang canonical constructor ngầm định, đúng nơi Hibernate Validator đọc. Thất bại khi bạn khai canonical constructor tường minh đủ tham số — annotation trên component không còn tự copy sang, constraint biến mất mà không cảnh báo.
Q4Thứ tự nào đúng khi Jackson bind title toàn khoảng trắng vào record có compact constructor strip() và @NotBlank?▸
Constructor nhận title thô → compact constructor strip thành chuỗi rỗng → gán vào field final → Bean Validation chạy sau đó, thấy chuỗi rỗng → @NotBlank bắt đúng. Thiếu bước strip, chuỗi toàn khoảng trắng có thể lọt qua.
Q5Liệt kê ba lý do JPA entity không thể là record — thiếu một lý do có đủ làm record thất bại không?▸
Constructor rỗng, field mutable, class không final để proxy — ba điểm chặn độc lập, chỉ cần đúng một điểm đã đủ vô hiệu hoá record làm entity.
Q6Vì sao TaskSummary (record) dùng được trong constructor expression JPQL, còn TaskEntity thì không thể là record?▸
Constructor expression chỉ cần một constructor đủ tham số gọi đúng một lần khi build kết quả đọc — record đáp ứng thừa. TaskEntity phải được Hibernate quản lý xuyên vòng đời (đổ dữ liệu sau, sửa field khi flush, proxy hoá) — record không hỗ trợ việc nào trong số đó.
Bài tiếp theo: Virtual threads trong Boot
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