Java Records: khi nào nên dùng và khi nào không
1. Tổng quan
Trước Java 14, muốn có một class chỉ chứa dữ liệu bất biến, bạn phải tự viết constructor, getter, equals(), hashCode(), toString() — toàn boilerplate lặp đi lặp lại. Record giải quyết đúng vấn đề này: bạn khai báo một dòng, Java tự sinh ra tất cả.
Bài này đi qua cú pháp cơ bản của record, cách tùy biến validation, so sánh với class thường và Lombok, rồi chỉ ra rõ khi nào nên dùng record và khi nào nó không phù hợp. Đọc xong, bạn sẽ biết cách áp dụng record đúng chỗ trong một ứng dụng Spring Boot thực tế.
2. Record là gì
Record là một loại class đặc biệt, dùng để mô hình hóa dữ liệu bất biến (immutable data). Khai báo một record tương đương với việc định nghĩa các “thành phần” (components) của nó:
public record Point(int x, int y) {
}
Chỉ với một dòng này, compiler tự sinh ra:
- Constructor nhận
x,y. - Accessor method
x()vày()(không có tiền tốget). equals(),hashCode()dựa trên tất cả các component.toString()dạngPoint[x=1, y=2].
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1);
System.out.println(p1.equals(p2));
System.out.println(p1.x());
Point[x=1, y=2]
true
1
Lưu ý: Record tự động
final— không thể extend record, và các field của nó cũngfinal, không thể gán lại sau khi khởi tạo.
3. Các thành phần của record
3.1. Constructor và validation
Record có constructor chính (canonical constructor) được sinh tự động, nhưng bạn có thể viết lại để thêm validation. Đây gọi là compact constructor — không cần khai báo lại tham số:
public record Order(String id, BigDecimal amount) {
public Order {
if (amount == null || amount.signum() < 0) {
throw new IllegalArgumentException("amount phải >= 0");
}
}
}
Dòng public Order { ... } không có danh sách tham số — nó dùng chung với tham số của record. Validation chạy trước khi field được gán, nên bạn chặn được dữ liệu sai ngay từ lúc tạo object.
new Order("O1", new BigDecimal("-10"));
Exception in thread "main" java.lang.IllegalArgumentException: amount phải >= 0
3.2. Thêm method tùy biến
Record vẫn có thể chứa method, static field, static method như class thường:
public record Money(String currency, BigDecimal value) {
public Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalStateException("Khác loại tiền tệ");
}
return new Money(currency, value.add(other.value));
}
public static Money zero(String currency) {
return new Money(currency, BigDecimal.ZERO);
}
}
Vì value là final, method add() không sửa object hiện tại — nó trả về một Money mới. Đây chính là tinh thần bất biến mà record hướng tới.
3.3. Implement interface
Record có thể implement interface (nhưng không thể extend class khác):
public interface Shape {
double area();
}
public record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
}
4. Record trong Spring Boot: DTO và request/response
Trường hợp dùng phổ biến nhất của record là DTO — dữ liệu truyền vào/ra qua REST API.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CustomerRequest(
@NotBlank String name,
@Email String email
) {
}
@RestController
@RequestMapping("/customers")
public class CustomerController {
@PostMapping
public ResponseEntity<CustomerResponse> create(@Valid @RequestBody CustomerRequest request) {
Customer saved = new Customer(1L, request.name(), request.email());
return ResponseEntity.ok(new CustomerResponse(saved.id(), saved.name()));
}
}
public record CustomerResponse(Long id, String name) {
}
Jackson (được Spring Boot cấu hình sẵn) deserialize JSON thành record thông qua canonical constructor, không cần setter. Bean Validation annotation (@NotBlank, @Email) đặt trực tiếp trên tham số của record vẫn hoạt động bình thường vì Spring tự nhận các annotation này ở constructor.
POST /customers
{"name": "", "email": "a@b.com"}
HTTP 400 Bad Request
Lưu ý: Nếu record dùng làm JPA entity, bạn sẽ gặp vấn đề — JPA cần no-arg constructor và field có thể thay đổi để lazy-loading hoạt động. Record không hỗ trợ việc này, nên không dùng record làm
@Entity.
5. Record so với class thường
| Tiêu chí | Class thường | Record |
|---|---|---|
| Boilerplate | Tự viết getter, equals, hashCode, toString |
Tự sinh |
| Mutable | Có thể | Không (field luôn final) |
| Kế thừa | Extend được class khác | Không extend được (implicit final) |
| Phù hợp cho | Entity, class có state thay đổi | DTO, value object, dữ liệu bất biến |
So sánh cụ thể qua ví dụ cùng một value object FullName:
// Cách viết cũ
public final class FullNameOld {
private final String first;
private final String last;
public FullNameOld(String first, String last) {
this.first = first;
this.last = last;
}
public String getFirst() { return first; }
public String getLast() { return last; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof FullNameOld other)) return false;
return Objects.equals(first, other.first) && Objects.equals(last, other.last);
}
@Override
public int hashCode() {
return Objects.hash(first, last);
}
@Override
public String toString() {
return "FullNameOld[first=" + first + ", last=" + last + "]";
}
}
// Cách viết với record
public record FullName(String first, String last) {
}
Hai class này về mặt hành vi tương đương, nhưng record ngắn hơn rất nhiều và không có khả năng viết sai equals/hashCode (lỗi kinh điển khi tự code tay).
6. Record so với Lombok @Value
Nhiều team dùng Lombok @Value để giảm boilerplate trước khi có record. Hai cách này giải quyết vấn đề tương tự, nhưng khác bản chất:
// Lombok
@Value
public class ProductDto {
String sku;
BigDecimal price;
}
// Record
public record ProductDto(String sku, BigDecimal price) {
}
Khác biệt chính:
- Record là tính năng native của Java, không cần thêm dependency, không cần annotation processor.
- Record tự sinh accessor dạng
sku()(không cóget), trong khi Lombok sinhgetSku(). - Record không hỗ trợ kế thừa hay thêm field tùy ý qua builder pattern dễ như Lombok (
@Buildervẫn phổ biến hơn cho object có nhiều field optional).
Lưu ý: Nếu project đã dùng Lombok rộng rãi và cần
@Buildercho DTO nhiều field, có thể giữ Lombok cho trường hợp đó. Record phù hợp hơn khi DTO đơn giản, ít field, không cần builder.
7. Khi nào nên dùng record
Dùng record khi dữ liệu của bạn:
- Bất biến theo bản chất: tọa độ, khoảng tiền, khoảng thời gian, kết quả tính toán.
- DTO truyền giữa các layer: request/response của API, dữ liệu trả về từ query, message gửi qua queue.
- Value object trong domain model:
Money,EmailAddress,Coordinates— những thứ so sánh bằng giá trị, không bằng identity. - Kết quả tạm trong logic xử lý: gom nhiều giá trị trả về từ một method mà không cần tạo class riêng cầu kỳ.
public record ValidationResult(boolean valid, List<String> errors) {
public static ValidationResult ok() {
return new ValidationResult(true, List.of());
}
}
8. Khi nào không nên dùng record
8.1. JPA entity
Như đã nói ở mục 4, JPA cần entity mutable, có no-arg constructor, và hỗ trợ proxy cho lazy loading. Record không đáp ứng được.
// KHÔNG nên làm thế này
@Entity
public record Customer(@Id Long id, String name) {
}
Hibernate sẽ gặp lỗi hoặc hành vi không mong muốn khi cố tạo proxy cho record. Hãy dùng class thường (hoặc Lombok @Entity + @Getter/@Setter) cho entity.
8.2. Object cần thay đổi state liên tục
Nếu object của bạn có state thay đổi nhiều lần trong vòng đời (ví dụ ShoppingCart thêm/xóa item liên tục), ép nó thành record buộc bạn phải tạo object mới mỗi lần thay đổi — tốn kém và không tự nhiên.
// Không hợp lý: tạo cart mới mỗi lần add item
public record ShoppingCart(List<Item> items) {
public ShoppingCart addItem(Item item) {
List<Item> newItems = new ArrayList<>(items);
newItems.add(item);
return new ShoppingCart(newItems);
}
}
Trường hợp này, một class mutable thông thường với List<Item> và method addItem() sửa trực tiếp sẽ tự nhiên và hiệu quả hơn.
8.3. Cần kế thừa (inheritance)
Record implicit final và không thể extend class khác (chỉ implement interface). Nếu domain model của bạn cần hierarchy class (Employee → Manager, Developer), record không phù hợp — hãy dùng class thường hoặc sealed interface/class kết hợp record cho từng nhánh lá.
public sealed interface Shape permits Circle, Square {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
Đây là cách dùng record hợp lý trong hierarchy: record làm các nhánh lá bất biến, sealed interface quản lý tập hợp các loại con.
8.4. Cần tương thích serialization phức tạp
Record implement Serializable được, nhưng nếu bạn cần custom serialization phức tạp (versioning, backward compatibility nhiều năm), class thường với kiểm soát chi tiết hơn vẫn an toàn hơn.
9. Kiểm thử record
Vì equals()/hashCode() được sinh tự động dựa trên component, test rất đơn giản với AssertJ:
class MoneyTest {
@Test
void shouldBeEqualWhenSameValues() {
Money a = new Money("VND", new BigDecimal("1000"));
Money b = new Money("VND", new BigDecimal("1000"));
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
void shouldThrowWhenAddingDifferentCurrency() {
Money vnd = new Money("VND", BigDecimal.TEN);
Money usd = new Money("USD", BigDecimal.ONE);
assertThatThrownBy(() -> vnd.add(usd))
.isInstanceOf(IllegalStateException.class)
.hasMessage("Khác loại tiền tệ");
}
}
assertThat(a).isEqualTo(b) kiểm tra đúng hành vi equals() tự sinh — hai record với giá trị component giống nhau phải bằng nhau, dù là hai instance khác nhau trong memory.
10. Kết luận
Record là công cụ tốt nhất Java cung cấp cho dữ liệu bất biến: ít code, ít lỗi, rõ ý định. Dùng nó cho DTO, value object, kết quả trả về tạm thời. Đừng dùng cho JPA entity, object có state thay đổi liên tục, hoặc khi bạn cần class hierarchy phức tạp qua kế thừa. Chọn đúng công cụ cho đúng việc — record không thay thế hoàn toàn class thường, nó bổ sung một lựa chọn gọn hơn cho một nhóm bài toán cụ thể.