Trong hai mươi năm, một lớp "chỉ chứa dữ liệu" trong Java luôn đi kèm khoảng ba mươi dòng nghi lễ: trường private final, constructor, getter, rồi equals, hashCode, toString viết tay hoặc nhờ IDE sinh. Từ Java 16, Java record thay toàn bộ nghi lễ đó bằng một dòng khai báo, và từ Java 17, sealed cho phép ta đóng kín tập các kiểu con. Ghép hai thứ với switch pattern matching của Java 21, ta có kiểu tổng (sum type) kiểm tra được lúc biên dịch: thêm một biến thể mà quên xử lý thì code không biên dịch. Bài này đi qua cả ba thứ trên code chạy thật, chỉ ra chỗ Java record "bất biến" thực ra chỉ bất biến nông, và dùng chúng để mô hình hoá thanh toán và trạng thái đơn hàng.

Đây là bài cuối của series Java & Design Pattern. Bài trước là Refactoring sang design pattern: một case study từ code thật, nơi record đã xuất hiện như công cụ gói CouponEffect; và Visitor pattern thời Java 21: sealed interface và pattern matching, nơi sealed được dùng để thay Visitor. Toàn bộ code nằm trong code/s6-36, chạy bằng JDK 21.0.12 trên Apple M4, 16 GB RAM; Jackson 2.17.2 lấy từ kho Maven cục bộ (cùng phiên bản mà Spring Boot 3.3.5 sử dụng).

Java record là gì và không phải là gì

Theo JEP 395 (Java record được xem trước ở JDK 14 và 15, chính thức ở JDK 16), mục tiêu của Java record là một cấu trúc ngắn gọn để biểu diễn tập hợp giá trị đơn giản, mô hình hoá dữ liệu bất biến thay vì hành vi mở rộng được. JEP nói rõ giảm boilerplate chỉ là hệ quả phụ, không phải mục tiêu. Trình biên dịch cho mỗi thành phần một trường private final và một accessor công khai cùng tên, cho một canonical constructor khớp phần header, và sinh equals, hashCode, toString dựa trên thành phần. Hai Java record bằng nhau khi cùng kiểu và các thành phần bằng nhau.

Những giới hạn của Java record cũng quan trọng không kém: không có mệnh đề extends (lớp cha luôn là java.lang.Record), ngầm final, không có trường instance ngoài các thành phần trong header, nhưng được phép có phương thức, thành phần tĩnh, kiểu lồng nhau, generic và implements interface. Tài liệu Java 21 Language Updates của Oracle bổ sung: accessor tường minh phải public và cùng kiểu trả về, còn equals/hashCode/toString tuỳ biến phải giữ đúng ngữ nghĩa của java.lang.Record.

Reflection cho thấy Java record chỉ có một trường final, lớp cha là java.lang.Record, cùng các phương thức do trình biên dịch sinh. Một Value Object Sku bằng Java record tốn 9 dòng, còn viết tay kiểu trước Java 16 (classic/SkuClassic.java) tốn 36 dòng cho cùng hành vi.

superclass = java.lang.Record
trường (fields):
  private final java.math.BigDecimal demo.Values$Money.amount
record Sku (khai báo trong Values.java, dòng 12-20): 9 dòng
SkuClassic.java (cùng hành vi, viết tay):                 36 dòng

Compact constructor: nơi Java record tự bảo vệ

Điều khiến Java record hữu ích cho mô hình miền là compact constructor: khai báo không có danh sách tham số, chạy trước phép gán trường, dùng để kiểm tra hoặc chuẩn hoá. JEP 395 nhấn mạnh trong thân compact constructor ta không được gán trực tiếp trường; các trường được gán từ tham số ở cuối. Muốn chuẩn hoá thì gán lại tham số, như Email dưới đây chuẩn hoá chữ thường, còn Money làm tròn về đồng và từ chối số âm:

    public record Email(String value) {
        public Email {
            value = value.strip().toLowerCase();      // chuẩn hoá: gán lại tham số, không gán this.value
            if (!value.contains("@")) {
                throw new IllegalArgumentException("Email không hợp lệ: " + value);
            }
        }
    }

    public record Money(BigDecimal amount) {
        public Money {
            amount = amount.setScale(0, RoundingMode.HALF_UP);
            if (amount.signum() < 0) throw new IllegalArgumentException("Số tiền âm: " + amount);
        }

Kiểm tra bằng Demo1Records, phần 1 và 2:

=== 1. equals, hashCode, toString sinh sẵn ===
a = Sku[value=ABC-1234]
a.equals(b) = true, cùng hashCode = true, a == b: false
Set có 2 phần tử trùng giá trị -> size = 1

=== 2. Compact constructor: kiểm tra và chuẩn hoá ===
Email[value=an.nguyen@example.test]
true
Từ chối: SKU không hợp lệ: abc-12
Từ chối: Số tiền âm: -5
Từ chối: Số lượng phải > 0
Money[amount=1001]  (làm tròn về đồng)

Hai kết quả đáng chú ý. Thứ nhất, hai Sku khác tham chiếu nhưng cùng giá trị thì equals đúng, cùng hashCode, và nhét vào HashSet chỉ còn một phần tử. Thứ hai, new Email("AN@x.test").equals(new Email(" an@X.TEST ")) là true vì chuẩn hoá chạy trước khi so sánh: bất biến được thiết lập ngay lúc tạo, nên không thể tồn tại một Sku sai định dạng hay một Money âm. Đây chính là tinh thần Value Object đã bàn ở bài DDD trong Java: Entity, Value Object và Aggregate, nhưng giờ tốn rất ít code.

Bất biến nông: cái bẫy lớn nhất của Java record

Oracle mô tả trường của Java record là final vì lớp được thiết kế như một "data carrier" đơn giản; điều đó không có nghĩa mọi thứ Java record trỏ tới đều bất biến. Nếu thành phần là một List khả biến, ai giữ tham chiếu tới danh sách gốc vẫn sửa được nội dung record từ bên ngoài. ShallowCart trong Values.java cố tình không phòng thủ, còn SafeCart dùng List.copyOf trong compact constructor (hàm này cũng ném lỗi với null). Cũng cần nhớ Java record không có setter, nên muốn "đổi" thì tạo bản mới, ví dụ phương thức withLine:

    /** Phiên bản đúng: List.copyOf trong compact constructor (cũng từ chối null). */
    public record SafeCart(String id, List<OrderLine> lines) {
        public SafeCart {
            lines = List.copyOf(lines);
        }

        public Money total() { return lines.stream().map(OrderLine::total).reduce(new Money(BigDecimal.ZERO), Money::plus); }

        /** "Wither": record không có setter, muốn đổi thì tạo bản mới. */
        public SafeCart withLine(OrderLine line) {
            var next = new java.util.ArrayList<>(lines);
            next.add(line);
            return new SafeCart(id, next);
        }
    }

Phần 3 của Demo1Records tạo hai giỏ từ cùng một danh sách rồi sửa danh sách gốc sau khi đã tạo:

=== 3. Bất biến nông: record chứa List ===
ShallowCart thấy 2 dòng (bị đổi từ bên ngoài)
SafeCart thấy    1 dòng, tổng 100000 VND
safe.lines().add(...) -> UnsupportedOperationException
withLine tạo bản mới: gốc 1 dòng, bản mới 2 dòng

ShallowCart "thấy" 2 dòng: dòng thứ hai được thêm từ bên ngoài sau khi giỏ đã tạo, nghĩa là một giỏ hàng bất biến trên giấy đã bị đổi, kể cả tổng tiền. SafeCart vẫn là 1 dòng, thêm vào lines() ném UnsupportedOperationException, và withLine cho bản mới mà không đụng bản gốc. Quy tắc: thành phần kiểu collection phải được sao chép phòng thủ, thành phần kiểu khả biến khác (Date, mảng) cũng vậy.

Cái bẫy thứ hai của Java record là mảng. Phần 4 của demo:

=== 4. Thành phần mảng: equals so sánh tham chiếu ===
cùng nội dung mảng, equals = false
toString = WithArray[name=n, data=[I@<hash>]

Hai Java record có mảng cùng nội dung lại không bằng nhau, vì equals của record so sánh từng thành phần bằng equals của chính thành phần, mà mảng chỉ so sánh tham chiếu; toString in ra mã băm kiểu [I@... vô nghĩa. Với Java record, giải pháp là dùng List<Integer> hoặc một Value Object, hoặc tự ghi đè equals, hashCode và toString bằng Arrays.equals, Arrays.hashCode, Arrays.toString (JEP 395 cho phép, miễn giữ đúng ngữ nghĩa).

Sealed: đóng kín tập các kiểu con

JEP 409 (chính thức ở JDK 17, sau hai lần xem trước ở JDK 15 và 16) cho phép một lớp hoặc interface giới hạn ai được kế thừa bằng sealed và mệnh đề permits. Mỗi lớp con được phép phải dùng đúng một trong ba modifier: final (không kế thừa tiếp), sealed (kế thừa có kiểm soát tiếp) hoặc non-sealed (mở lại cho mọi người). Các lớp con phải ở cùng module (hoặc cùng package nếu ở unnamed module); nếu chúng nằm trong cùng một tệp nguồn thì có thể bỏ permits và trình biên dịch tự suy ra. JEP 409 lưu ý Java record ngầm là final nên rất hợp làm lớp con, và ghép sealed với record cho ta các kiểu dữ liệu đại số. Đây là mô hình phương thức thanh toán:

public sealed interface PaymentMethod {
    record Card(String last4, int installments) implements PaymentMethod {
        public Card {
            if (!last4.matches("\\d{4}")) throw new IllegalArgumentException("last4 phải đủ 4 chữ số");
            if (installments < 1 || installments > 12) throw new IllegalArgumentException("Trả góp 1-12 kỳ");
        }
    }

    record BankTransfer(String bank, String reference) implements PaymentMethod {}

    record Wallet(String provider, Money balance) implements PaymentMethod {}

    record CashOnDelivery() implements PaymentMethod {}
}

Mỗi biến thể là một Java record mang đúng dữ liệu của nó: thẻ có bốn số cuối và số kỳ trả góp (với compact constructor kiểm tra), chuyển khoản có ngân hàng và nội dung, ví có số dư, còn thanh toán khi nhận hàng không mang gì. Không còn một lớp Payment với bảy trường nullable mà ta phải nhớ trường nào dùng cho loại nào.

Pattern matching switch: kiểm tra đầy đủ lúc biên dịch

Sức mạnh thật sự xuất hiện khi dùng switch. Theo JEP 441 (hoàn thiện ở Java 21 sau bốn vòng xem trước), một switch dùng pattern phải exhaustive, và với phân cấp sealed, trình biên dịch xác nhận độ phủ từ mệnh đề permits nên thường không cần default. JEP còn nêu rõ ưu tiên một switch exhaustive không có nhánh match-all hơn là có default, vì khi thêm kiểu con mới, default sẽ im lặng nuốt nó. Kèm theo là guard when (chỉ pattern mới có guard) và quy tắc dominance: nhãn bị nhãn đứng trước che phủ là lỗi biên dịch. JEP 440 bổ sung record pattern, cho phép bóc thành phần ngay trong case, lồng nhau, dùng var, và null không khớp record pattern nào:

    public static long fee(PaymentMethod m, long amount) {
        return switch (m) {
            case Card c when c.installments() > 1 -> amount * 3 / 100;      // trả góp: 3%
            case Card c -> amount * 2 / 100;                                // trả thẳng: 2%
            case BankTransfer b -> 0;
            case Wallet w -> amount * 1 / 100;
            case CashOnDelivery cod -> 15_000;
        };
    public static String describe(PaymentMethod m) {
        return switch (m) {
            case Card(var last4, var n) when n > 1 -> "Thẻ ****" + last4 + " trả góp " + n + " kỳ";
            case Card(var last4, var n) -> "Thẻ ****" + last4;
            case BankTransfer(var bank, var ref) -> "Chuyển khoản " + bank + ", nội dung " + ref;
            case Wallet(var provider, var balance) -> "Ví " + provider + ", số dư " + balance.amount();
            case CashOnDelivery() -> "Thanh toán khi nhận hàng";
        };

Chạy Demo2Sealed:

=== 1. switch exhaustive trên sealed interface ===
Thẻ ****4242                               phí trên 2.000.000 = 40,000
Thẻ ****4242 trả góp 6 kỳ                  phí trên 2.000.000 = 60,000
Chuyển khoản VCB, nội dung DH001           phí trên 2.000.000 = 0
Ví MoMo, số dư 250000                      phí trên 2.000.000 = 20,000
Thanh toán khi nhận hàng                   phí trên 2.000.000 = 15,000

=== 2. Reflection trên sealed ===
isSealed = true
  permits Card
  permits BankTransfer
  permits Wallet
  permits CashOnDelivery

Hai chi tiết đáng nhìn trong code. Thứ nhất, hai nhánh Card đi theo thứ tự "guard trước, không guard sau" đúng như khuyến nghị của JEP 441; đảo thứ tự sẽ là lỗi biên dịch vì nhánh không guard che nhánh có guard. Thứ hai, không có default nào ở cả hai switch. Hàm describe dùng case Card(var last4, var n) để lấy thành phần mà không gọi accessor tay. Reflection xác nhận cấu trúc: isSealed = true và getPermittedSubclasses() liệt kê bốn biến thể theo thứ tự khai báo.

Thêm biến thể mà quên xử lý: lỗi biên dịch, không phải lỗi production

Đây là lợi ích thực dụng nhất. Demo4Compile.java cố tình thêm phương thức Qr mà switch chưa có nhánh:

    static long fee(Pay p) {
        return switch (p) {
            case Card c -> 2;
            case Cod c -> 15_000;
            // quên nhánh Qr
        };
    }

Biên dịch bằng javac (JDK 21.0.12) cho kết quả thật:

src/demo/Demo4Compile.java:11: error: the switch expression does not cover all possible input values
        return switch (p) {
               ^
1 error

So với enum cộng switch có default, hoặc cây lớp với phương thức ảo, sealed cho ta một điểm giữa: dữ liệu và thao tác tách rời (thêm thao tác mới không sửa các record), nhưng tập biến thể đóng kín (thêm biến thể buộc sửa mọi switch). Đây chính xác là đánh đổi mà bài về Visitor đã phân tích.

Làm cho trạng thái bất hợp lệ không thể biểu diễn

Máy trạng thái ở bài State pattern và máy trạng thái cho đơn hàng dùng lớp trạng thái với hành vi. Với sealed và Java record ta có một cách khác, thiên về dữ liệu: mỗi trạng thái chỉ mang dữ liệu thuộc về nó. Draft không có receipt nên không thể có "đơn nháp có biên lai"; Shipped bắt buộc có trackingNo nên không thể có "đơn đã giao mà không có mã vận đơn":

    static OrderState ship(OrderState s, String trackingNo) {
        return switch (s) {
            case Paid(var at, var receipt) -> new Shipped(at, receipt, trackingNo);
            case Draft d -> throw new IllegalStateException("Đơn nháp chưa thanh toán, không thể giao");
            case Shipped sh -> throw new IllegalStateException("Đơn đã giao với mã " + sh.trackingNo());
            case Cancelled c -> throw new IllegalStateException("Đơn đã huỷ: " + c.reason());
        };
    }

Phần 3 và 4 của Demo2Sealed chạy chuyển trạng thái và các trường hợp bị từ chối:

=== 3. Máy trạng thái bằng sealed record ===
Shipped[paidAt=2026-10-01T03:00:00Z, receipt=RC-77, trackingNo=VN123456]
Từ chối: Đơn nháp chưa thanh toán, không thể giao
Từ chối: Đơn đã giao với mã VN123456
Từ chối: Đơn đã huỷ: khách đổi ý

=== 4. Thành phần không hợp lệ bị từ chối khi tạo ===
Từ chối: last4 phải đủ 4 chữ số
Từ chối: Trả góp 1-12 kỳ

Chỉ một đường hợp lệ (Paid thành Shipped), ba đường còn lại ném lỗi rõ ràng; thêm trạng thái Returned thì hàm ship không biên dịch cho tới khi ta quyết định hành vi. Đây là tinh thần lập trình hướng dữ liệu (data-oriented programming) mà Brian Goetz mô tả: dữ liệu bất biến biểu diễn bằng sum type và product type (sealed và Java record), logic nằm ở hàm dùng pattern matching.

Java record và hệ sinh thái: JSON, serialization, JPA

Dữ liệu thực tế phải đi qua biên: JSON qua HTTP, byte qua hàng đợi, bảng qua database. Câu hỏi là các bất biến của record có sống sót qua biên không. Demo3Json thử với Jackson 2.17.2 (phiên bản Spring Boot 3.3.5 dùng; Jackson hỗ trợ record từ dòng 2.12):

=== 1. Jackson + record: tuần tự hoá và đọc lại ===
{"sku":{"value":"ABC-0001"},"quantity":2,"unitPrice":{"amount":150000}}
đọc lại bằng nhau: true

=== 2. Dữ liệu xấu đi qua JSON vẫn bị compact constructor chặn ===
ValueInstantiationException <- IllegalArgumentException: SKU không hợp lệ: sai

=== 3. Sealed interface đa hình trong JSON ===
{"type":"card","last4":"4242"}   |   {"type":"cod"}
đọc lại: Card[last4=4242]

=== 4. Java serialization: record dựng lại qua canonical constructor ===
giải tuần tự hoá: Note[text=xin chào]

Có bốn điều rút ra. Một, Java record tuần tự hoá và đọc lại bằng Jackson không cần annotation, kết quả equals bản gốc.

Hai, dữ liệu xấu đi qua JSON vẫn bị compact constructor chặn: Jackson gọi canonical constructor nên lỗi SKU không hợp lệ: sai được bọc trong ValueInstantiationException; bất biến nằm trong miền chứ không phải trong lớp controller, đúng nguyên tắc ở bài DDD. Ba, sealed interface đa hình cần @JsonTypeInfo và @JsonSubTypes để ghi tên kiểu vào JSON (trường type); không có chúng, Jackson không biết dựng biến thể nào khi đọc. Bốn, theo JEP 395, serialization chuẩn của Java dùng canonical constructor khi giải tuần tự hoá, nên một record có kiểm tra không thể bị "lách" bằng byte đã chỉnh sửa, khác với lớp thường nơi trường được gán trực tiếp; phần 4 của demo xác nhận một Note được dựng lại đúng qua ObjectInputStream.

Với JPA thì khác: @Entity cần constructor không đối số và trường khả biến, nên không dùng Java record làm entity. Chỗ hợp lý trong tầng persistence là kiểu giá trị nhúng và DTO/projection cho truy vấn đọc.

Khi nào không nên dùng Java record và sealed

  • Đối tượng có danh tính và vòng đời: entity so sánh theo id, nên equals sinh sẵn của Java record sai ngữ nghĩa ở đây.
  • Cần kế thừa hoặc trạng thái khả biến: record ngầm final, không có trường instance ngoài header.
  • Mô hình mở: sealed đóng kín tập biến thể; nếu bên thứ ba cần thêm kiểu (plugin, SPI), dùng interface thường hoặc nhánh non-sealed.
  • Miền biến động nhanh theo chiều biến thể: mỗi biến thể mới buộc sửa mọi switch; đáng với miền ổn định (phương thức thanh toán, trạng thái đơn), tốn kém với miền hay đổi.
  • API công khai lâu dài: đổi thành phần của Java record là đổi cả constructor, accessor, equals và dạng serialize.

Lỗi thường gặp với Java record và sealed

  • Tưởng Java record bất biến sâu: luôn List.copyOf, Map.copyOf trong compact constructor.
  • Thành phần mảng: equals theo tham chiếu; dùng List hoặc ghi đè thủ công.
  • Gán this.field trong compact constructor: không biên dịch; chuẩn hoá bằng cách gán lại tham số.
  • Dùng default trong switch trên sealed: biến thể mới rơi vào default mà không có cảnh báo.
  • Nhánh không guard đứng trước nhánh có guard: lỗi biên dịch vì dominance.
  • Dùng Java record làm @Entity: Hibernate cần constructor mặc định và khả năng gán trường.

Checklist

  • Dữ liệu thuần, so sánh theo giá trị, không cần kế thừa: dùng Java record; kiểm tra và chuẩn hoá trong compact constructor.
  • Thành phần collection hoặc khả biến: sao chép phòng thủ; tránh thành phần mảng.
  • Tập biến thể đóng kín: sealed interface cùng các record, mỗi biến thể chỉ mang dữ liệu của nó.
  • Switch pattern không có default; guard trước, không guard sau.
  • Entity JPA dùng lớp thường; Java record cho giá trị nhúng và DTO.

Tóm lại, Java record cho ta Value Object gần như miễn phí, sealed cho ta tập biến thể đóng kín, và pattern matching nối hai thứ bằng kiểm tra lúc biên dịch. Chúng không thay thế mọi lớp, nhưng với dữ liệu thuần và trạng thái miền, đây là cách ngắn nhất để biến "quy tắc nghiệp vụ trong tài liệu" thành "quy tắc nghiệp vụ trình biên dịch giám sát".

Kết series

Ba mươi sáu bài của series đi từ nguyên lý tới thực hành: nền tảng với Nguyên lý SOLID trong Java qua ví dụ thực tế; các pattern tạo đối tượng, cấu trúc và hành vi như Builder, Decorator, Strategy, State; kiến trúc doanh nghiệp với Dependency Injection, DDD, Hexagonal Architecture, Saga; Java hiện đại với virtual threads và bài này. Hai bài gần cuối, Anti-pattern trong Java và Testing patterns, nhắc rằng pattern chỉ có giá trị khi dùng đúng chỗ và được test bảo vệ.

Ba điều xuyên suốt. Một, pattern là ngôn ngữ chung và là đích đến của refactoring, không phải mục tiêu: hãy bắt đầu từ nỗi đau cụ thể. Hai, mỗi tính năng mới của Java (lambda, Java record, sealed, pattern matching) làm nhẹ nhiều pattern kinh điển, nên cài đặt ngày nay đơn giản hơn nhiều so với sách năm 1994. Ba, mọi khẳng định trong series đều đi kèm code chạy được và output thật; hãy chạy lại, thay đổi, và nếu kết quả trên máy bạn khác, đó là chỗ đáng học nhất. Cảm ơn bạn đã đọc đến hết series.