Bài trước bạn viết equals, hashCode, toString cho class Book — tổng cộng ~30 dòng cho một class thực ra chỉ là bộ dữ liệu. Java nhận ra pattern này xuất hiện khắp mọi codebase và giải quyết bằng record (Java 16+): 1 dòng khai báo, compiler tự sinh constructor, accessor, và 3 method Object quan trọng.
Bài này giải thích record đầy đủ: cú pháp, compact constructor để validate, field phụ, khi nào không dùng record, và vì sao nhiều team di chuyển POJO sang record.
1. Analogy — biểu mẫu khai báo
Viết class truyền thống với field + constructor + getter + equals/hashCode/toString giống phải viết báo cáo chi tiết 5 trang chỉ để khai "tên, tuổi, địa chỉ". Record như một biểu mẫu 1 dòng: điền 3 ô, xong — mọi thủ tục còn lại hệ thống xử lý tự động.
| Đời thường | Java |
|---|---|
| Biểu mẫu 1 dòng | record Book(String title, int year) {} |
| Các ô trong biểu mẫu | Component (title, year) |
| Máy in tự điền thông tin khác | Compiler sinh constructor, accessor, equals... |
💡 💡 Cách nhớ
Record = class immutable lộ ra "hình dạng dữ liệu" qua header. Không cần boilerplate — compiler lo.
2. Cú pháp cơ bản
public record Book(String title, String author, int year) { }
Một dòng này sinh ra:
- Private final field cho mỗi component:
private final String title;v.v. - Canonical constructor
Book(String, String, int)gán tất cả field. - Accessor
title(),author(),year()— không phảigetTitle(). equals(Object)so dựa trên mọi component.hashCode()hash dựa trên mọi component.toString()trảBook[title=..., author=..., year=...].
Dùng:
Book b = new Book("Java", "Bloch", 2018);
System.out.println(b.title()); // Java — khong phai getTitle()
System.out.println(b); // Book[title=Java, author=Bloch, year=2018]
Book c = new Book("Java", "Bloch", 2018);
System.out.println(b.equals(c)); // true
System.out.println(b.hashCode() == c.hashCode()); // true
Viết class tương đương bằng tay: 30–40 dòng. Record: 1 dòng.
3. Compact constructor — validate input
Muốn validate mà không lặp lại danh sách parameter? Dùng compact constructor:
public record Book(String title, String author, int year) {
public Book {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("title must not be blank");
}
if (year < 0) {
throw new IllegalArgumentException("year must be non-negative, got " + year);
}
// khong can gan this.title = title — compiler lam sau body nay
}
}
Khác với constructor bình thường:
- Không có
()sau tên —public Book {, không phảipublic Book(...) {. - Không gán field — compiler tự làm sau body. Bạn chỉ validate.
- Có thể sửa parameter trước khi compiler gán:
public Book { title = title.trim(); // chuan hoa input }
3.1 Canonical constructor đầy đủ — khi cần logic phức tạp
Nếu compact không đủ, viết canonical constructor đầy đủ (lặp lại signature):
public record Book(String title, String author, int year) {
public Book(String title, String author, int year) {
this.title = Objects.requireNonNull(title);
this.author = Objects.requireNonNull(author);
this.year = year;
}
}
Hiếm cần — 90% trường hợp compact đủ và đẹp hơn.
4. Constructor overload — "static factory" pattern
Record chỉ có 1 canonical constructor. Muốn thêm constructor khác → chain qua this(...):
public record Book(String title, String author, int year) {
public Book(String title) {
this(title, "Unknown", 0); // chain sang canonical
}
}
Hoặc — khuyến nghị hơn — static factory method:
public record Book(String title, String author, int year) {
public static Book unknownAuthor(String title) {
return new Book(title, "Unknown", 0);
}
}
Book b = Book.unknownAuthor("Java");
Factory method có tên rõ nghĩa, dễ đọc hơn overload constructor.
5. Method thêm
Record cho phép method thường:
public record Book(String title, String author, int year) {
public int yearsOld() {
return java.time.Year.now().getValue() - year;
}
public String displayTitle() {
return title + " (" + year + ")";
}
}
new Book("Java", "Bloch", 2018).displayTitle(); // "Java (2018)"
Nhưng không thêm field mới ngoài component — record được thiết kế cho "shape of data" cố định. Static field OK:
public record Book(String title, String author, int year) {
public static final int MIN_YEAR = 1800; // static OK
// private int discount; // COMPILE ERROR — khong dat instance field ngoai component
}
6. Giới hạn — khi nào KHÔNG dùng record
- Cần mutable — record immutable toàn diện, mọi field
final. Cần setter? Dùng class. - Kế thừa — record không kế thừa được class khác (ngầm extend
Record). Record implement được interface, không extend. - Không phù hợp JPA entity — entity cần default constructor, mutable field cho proxy/dirty check, ID generation. Dùng class thường.
- Quá nhiều component — record với 15 component khó đọc. Thử nhóm thành record lồng (
Address,Contact, ...).
7. Record + pattern matching (Java 21)
Record + pattern matching cho switch cho code functional-style mạnh mẽ:
sealed interface Shape permits Circle, Square, Rectangle {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square sq -> sq.side() * sq.side();
case Rectangle r -> r.width() * r.height();
};
}
Kết hợp sealed interface + record + exhaustive switch — compiler đảm bảo phủ hết case. Đây là ADT (algebraic data type) trong Java. Chi tiết ở module kế thừa.
8. Khi nào nên dùng record?
✅ Dùng khi class thuần là data holder:
- DTO (data transfer object) trong REST API.
- Return value phức hợp từ method (tuple-like).
- Value object (
Money,Point,Range). - Config struct.
- Key cho
HashMap(tự cóequals/hashCode).
❌ Không dùng khi class có:
- State mutable (counter, cache nội bộ).
- Lifecycle phức tạp (init/destroy).
- JPA entity (như đã nói).
- Kế thừa từ class khác.
ℹ️ 📚 Record khuyến khích immutability
Record làm immutable "mặc định dễ hơn mutable". Đây là triết lý Java hiện đại: immutable → thread-safe, dễ đoán, cache được. Pattern functional trên record + stream ngày càng phổ biến. Nếu bạn băn khoăn "record hay class", mặc định chọn record trước — đổi sang class chỉ khi thật sự cần mutable.
9. Pitfall tổng hợp
❌ Nhầm 1: Gọi accessor như JavaBean.
book.getTitle(); // COMPILE ERROR
✅ Record accessor là title(), không getTitle(). Đây là khác biệt chính, phá convention JavaBean.
❌ Nhầm 2: Thêm instance field ngoài component.
public record Book(String title) {
private int views; // COMPILE ERROR
}
✅ Chỉ static field mới thêm được. Nếu cần instance field khác, không dùng record.
❌ Nhầm 3: Gán field trong compact constructor.
public Book {
this.title = title; // KHONG nen — compiler tu gan, day la thua/nguy hiem
}
✅ Chỉ validate và có thể sửa parameter; compiler gán sau.
❌ Nhầm 4: Tưởng record có thể extends.
public record Manager(String name) extends User {} // COMPILE ERROR
✅ Record không extends class. Implement interface được: record Manager(String name) implements Person {}.
❌ Nhầm 5: Record chứa collection mutable → "gần như immutable".
public record Cart(List<Item> items) {}
Cart c = new Cart(new ArrayList<>());
c.items().add(...); // van mutate duoc
✅ Wrap trong compact constructor:
public Cart {
items = List.copyOf(items);
}
10. 📚 Deep Dive Oracle
ℹ️ 📚 Deep Dive Oracle (optional)
Spec / reference chính thức:
- JEP 395 — Records — final trong Java 16. Motivation, design choices.
- JLS §8.10 — Record Classes — đầy đủ cú pháp, canonical constructor, derivation rules.
- JEP 406 — Pattern Matching for switch — preview; final ở JEP 441 trong Java 21.
- JEP 409 — Sealed Classes — final Java 17. Combo với record cho ADT.
- Effective Java (Bloch) Item 9: "In your public classes, use accessor methods, not public fields" — tinh thần record thực thi.
Ghi chú: JEP 395 viết rõ triết lý: record không phải "POJO boilerplate reducer" đơn thuần — nó là declaration of nominal tuple, với contract equality/hash/toString cố định. Đây là lý do record không có setter, không extends — "shape of data" của nó ổn định.
11. Tóm tắt
public record Book(String title, int year) {}— 1 dòng, compiler sinh: field, canonical constructor, accessor (title()),equals/hashCode/toString.- Compact constructor (
public Book {}không()) — validate + sửa parameter, compiler gán field sau. - Record immutable — mọi component là
final. - Không extends class được; implement interface được.
- Không thêm instance field ngoài component; static field OK; method thường OK.
- Dùng cho: DTO, value object, tuple return, map key, config struct.
- Tránh cho: mutable state, JPA entity, kế thừa class, lifecycle phức tạp.
- Record + sealed interface + pattern matching = ADT đầy đủ của Java.
- Immutability trong record chỉ thực sự nếu component là immutable;
List,Map... phải wrap copy trong compact constructor.
12. Tự kiểm tra
Q1Viết lại class sau dưới dạng record (giữ nguyên chức năng):public class Point {
private final double x;
private final double y;
public Point(double x, double y) { this.x = x; this.y = y; }
public double getX() { return x; }
public double getY() { return y; }
@Override public boolean equals(Object o) { ... }
@Override public int hashCode() { ... }
@Override public String toString() { ... }
}
▸
public class Point {
private final double x;
private final double y;
public Point(double x, double y) { this.x = x; this.y = y; }
public double getX() { return x; }
public double getY() { return y; }
@Override public boolean equals(Object o) { ... }
@Override public int hashCode() { ... }
@Override public String toString() { ... }
}public record Point(double x, double y) { }Một dòng. Compiler sinh: 2 private final field, canonical constructor, accessor x() / y(), equals / hashCode / toString.
Lưu ý accessor đổi tên: không còn getX()/getY(), mà là x()/y(). Nếu đang có caller dùng point.getX(), phải refactor — hoặc thêm method thủ công:
public record Point(double x, double y) {
public double getX() { return x; }
public double getY() { return y; }
}Nhưng thực tế thường nên cập nhật caller theo convention record.
Q2Đoạn sau compile không?public record User(String name, int age) {
public User {
if (age < 0) throw new IllegalArgumentException(...);
this.name = name.trim();
this.age = age;
}
}
▸
public record User(String name, int age) {
public User {
if (age < 0) throw new IllegalArgumentException(...);
this.name = name.trim();
this.age = age;
}
}Không compile. Trong compact constructor (public User {} không ()), bạn không được gán this.x = x — compiler tự làm sau body.
Viết this.name = name.trim() là compile error "cannot assign a value to final variable name" (vì compiler đã chuẩn bị chuỗi gán).
Fix: chỉ sửa parameter, compiler xử lý phần gán:
public User {
if (age < 0) throw new IllegalArgumentException(...);
name = name.trim(); // sua parameter — compiler gan 'this.name = name' sau body
}Q3Record sau có thật sự immutable không?public record Cart(String customerId, List<String> items) { }
Cart c = new Cart("A1", new ArrayList<>(List.of("apple", "banana")));
c.items().add("cherry");
System.out.println(c.items());
▸
public record Cart(String customerId, List<String> items) { }
Cart c = new Cart("A1", new ArrayList<>(List.of("apple", "banana")));
c.items().add("cherry");
System.out.println(c.items());Không — "shallowly immutable". In ra [apple, banana, cherry].
Field items là final — không gán lại được. Nhưng nó giữ reference đến ArrayList mutable — ai gọi c.items().add(...) vẫn sửa list gốc được.
Fix 1: wrap immutable trong compact constructor:
public record Cart(String customerId, List<String> items) {
public Cart {
items = List.copyOf(items); // snapshot immutable
}
}List.copyOf trả list immutable — c.items().add(...) sẽ ném UnsupportedOperationException. Đồng thời bảo vệ khỏi thay đổi từ bên ngoài: nếu caller sửa ArrayList gốc sau khi pass vào, c.items() vẫn giữ snapshot lúc tạo.
Fix 2: override accessor items() trả unmodifiable view — nhưng vẫn không chặn được caller sửa list gốc nếu giữ reference.
Q4Khi nào nên chọn class thay vì record?▸
Các case không phù hợp record:
- Mutable state: counter, cache, builder pattern. Record mọi field
final. - Extends class khác: record ngầm extends
java.lang.Record, không extends được class nào khác. - JPA entity: cần no-arg constructor, mutable field cho Hibernate proxy, ID generator. Nhiều framework chưa hỗ trợ record làm entity.
- Lifecycle phức tạp: open/close resource, subscribe/unsubscribe event, init/destroy — record không phù hợp với object "có trạng thái biến đổi theo thời gian".
- Shape data không cố định: cần instance field ngoài component (record cấm).
- Invariant lớn hơn component: vd class
Connectiongiữ state nội bộ, logic phức tạp — record hạn chế quá.
Default: nếu class là "bộ dữ liệu" immutable → record. Nếu không chắc → dùng record trước, đổi sang class khi gặp rào cản thực sự.
Q5Record có thể implement interface không? Extends class không? Ví dụ minh hoạ.▸
Implement interface — được:
interface Comparable<T> { int compareTo(T other); }
record Version(int major, int minor) implements Comparable<Version> {
@Override
public int compareTo(Version o) {
int m = Integer.compare(major, o.major);
return m != 0 ? m : Integer.compare(minor, o.minor);
}
}Extends class — KHÔNG:
class Base { }
record Child(int x) extends Base { } // COMPILE ERRORLý do: record ngầm extends java.lang.Record — Java cấm đa kế thừa class. Nhưng record có thể được extend bởi class khác? Cũng không — record ngầm final, không subclass được.
Do ràng buộc này, record rất hợp với sealed interface để làm ADT:
sealed interface Shape permits Circle, Square {}
record Circle(double r) implements Shape {}
record Square(double s) implements Shape {}Bài tiếp theo: Mini-challenge: Model hoá Library với record + class