Tất cả bài viết
JavaJava 25RecordsPattern MatchingBackend

Records, Sealed Interfaces và Pattern Matching: lập trình hướng dữ liệu trong Java hiện đại

30/09/20260 lượt xem12 phút đọc

Vì sao cần một cách mô hình hóa dữ liệu mới?

Trong nhiều năm, mô hình hóa dữ liệu trong Java gần như đồng nghĩa với: class, field private, getter/setter, equals/hashCode/toString — hoặc giao hết cho Lombok. Cách này ổn cho đến khi dữ liệu có nhiều hình dạng. Ví dụ kết quả một lần thanh toán có thể là thành công, bị từ chối hoặc cần xác thực thêm. Khi đó ta thường rơi vào một trong hai lựa chọn tệ: một class to với hàng loạt field nullable cộng cờ trạng thái, hoặc một hierarchy kèm Visitor pattern dài dòng.

Từ Java 16 đến Java 21, bộ ba records, sealed classes và pattern matching lần lượt được hoàn thiện. Kết hợp lại, chúng cho phép một phong cách mà Brian Goetz (Java Language Architect tại Oracle) gọi là data-oriented programming: mô hình hóa dữ liệu bất biến, rõ ràng, và để compiler kiểm tra thay bạn rằng mọi trường hợp đều đã được xử lý.

  • Java 16: records (JEP 395), pattern matching cho instanceof (JEP 394)
  • Java 17 (LTS): sealed classes (JEP 409)
  • Java 21 (LTS): pattern matching cho switch (JEP 441), record patterns (JEP 440)
  • Java 22: unnamed variables và patterns với _ (JEP 456)
  • Java 25 (LTS): primitive types trong patterns vẫn đang preview (JEP 507)

Toàn bộ code trong bài được chạy trên Java 25. Ngoại trừ ký hiệu _ cần Java 22 trở lên, mọi thứ khác đều dùng được từ Java 21.

Records: nhiều hơn một "class bớt boilerplate"

Record là một nominal tuple: bạn khai báo các thành phần (component), compiler sinh ra field private final, canonical constructor, accessor (tên là id() chứ không phải getId()) và equals/hashCode/toString dựa trên toàn bộ component. Nhưng giá trị thật của record nằm ở chỗ nó là một cam kết về ngữ nghĩa: object này chỉ là dữ liệu, trạng thái của nó chính là các component, không hơn không kém.

record Order(String id, List<String> items, BigDecimal total) {
    Order {
        Objects.requireNonNull(id, "id");
        if (total.signum() < 0) {
            throw new IllegalArgumentException("total phải >= 0");
        }
        items = List.copyOf(items); // defensive copy + immutable
    }
}

Khối Order { ... } không có danh sách tham số là compact canonical constructor. Bên trong nó, id, items, total là các tham số; việc gán vào field diễn ra tự động ở cuối constructor. Vì vậy dòng items = List.copyOf(items) gán lại tham số trước khi nó được lưu vào field. Đây là chỗ lý tưởng để validate và chuẩn hóa dữ liệu: một khi object đã được tạo ra, nó luôn hợp lệ.

var items = new ArrayList<>(List.of("book"));
var order = new Order("A1", items, new BigDecimal("99.00"));
items.add("pen"); // sửa list gốc sau khi đã tạo order

System.out.println(order);
order.items().add("hack");                          // ném exception
new Order("A2", List.of(), new BigDecimal("-1"));   // ném exception
Order[id=A1, items=[book], total=99.00]
items bất biến: UnsupportedOperationException
total phải >= 0

Record chỉ bất biến "nông"

Field của record là final, nhưng final chỉ khóa tham chiếu, không khóa object được tham chiếu. Nếu bỏ dòng List.copyOf, lệnh items.add("pen") ở ví dụ trên sẽ âm thầm làm thay đổi order — và cả hashCode của nó. Hậu quả kinh điển: bạn bỏ record vào HashSet, sửa list bên trong, rồi không bao giờ tìm lại được phần tử đó. Quy tắc: với mọi component là collection hoặc object mutable, hãy copy trong compact constructor.

Cẩn thận với mảng trong record

record Bytes(byte[] data) {}

new Bytes(new byte[]{1, 2}).equals(new Bytes(new byte[]{1, 2})); // false

equals sinh tự động so sánh từng component bằng equals của chính component đó, mà mảng thì so sánh theo tham chiếu. Nếu buộc phải chứa mảng, hãy tự override equals/hashCode bằng Arrays.equals/Arrays.hashCode (và cân nhắc toString với Arrays.toString), hoặc đổi sang List.

Nên và không nên dùng record ở đâu

  • Nên: DTO, request/response của API, value object (Money, Email, DateRange), key phức hợp cho Map, kết quả trung gian (record có thể khai báo cục bộ ngay trong method), projection khi query database. Jackson hỗ trợ record từ bản 2.12.
  • Không nên: JPA/Hibernate entity. Entity cần constructor không tham số, field có thể thay đổi và thường bị proxy — những thứ record cố tình không cho phép.

Sealed interfaces: đóng hierarchy lại

Record mô tả một hình dạng dữ liệu. Để mô tả "một trong vài hình dạng", ta dùng sealed interface:

sealed interface PaymentResult permits Success, Declined, RequiresAction {}

record Success(String transactionId, BigDecimal amount) implements PaymentResult {}
record Declined(String reason, boolean retryable) implements PaymentResult {}
record RequiresAction(URI redirectUrl) implements PaymentResult {}

permits liệt kê chính xác những kiểu được phép implement PaymentResult. Một vài quy tắc cần nhớ:

  • Mỗi kiểu con phải là final, sealed hoặc non-sealed. Record mặc định đã là final.
  • Kiểu con phải nằm cùng module (hoặc cùng package nếu project không dùng module).
  • Nếu tất cả kiểu con nằm chung một file, có thể bỏ hẳn mệnh đề permits, compiler tự suy ra.

Nếu đã quen với lý thuyết kiểu dữ liệu: record là product type (A và B), sealed interface là sum type (A hoặc B). Có thể hình dung sealed interface như một enum — nhưng thay vì một tập cố định các giá trị, đó là một tập cố định các kiểu, mỗi kiểu mang dữ liệu riêng của nó.

Pattern matching cho switch

Điều quan trọng nhất sealed interface mang lại: compiler biết toàn bộ các kiểu con. Nhờ đó switch có thể được kiểm tra tính đầy đủ (exhaustiveness):

static String describe(PaymentResult result) {
    return switch (result) {
        case Success(var txId, var amount) ->
            "Thanh toán " + amount + " thành công (" + txId + ")";
        case Declined(var reason, var retryable) when retryable ->
            "Bị từ chối (" + reason + "), thử lại sau";
        case Declined(var reason, _) ->
            "Bị từ chối: " + reason;
        case RequiresAction(var url) ->
            "Cần xác thực 3-D Secure tại " + url;
    };
}
Thanh toán 250000 thành công (tx_123)
Bị từ chối (insufficient_funds), thử lại sau
Bị từ chối: stolen_card
Cần xác thực 3-D Secure tại https://bank.example/3ds

Đoạn code nhỏ này dùng bốn tính năng cùng lúc:

  • Record pattern Success(var txId, var amount): vừa kiểm tra kiểu, vừa "bóc" component ra biến cục bộ.
  • Guard when retryable: điều kiện bổ sung cho case.
  • Unnamed pattern _: bỏ qua component không dùng đến.
  • Exhaustiveness: không có default, vì compiler đã biết bốn case này phủ hết mọi khả năng.

Compiler nhắc bạn khi thêm trường hợp mới

Đây là lý do nên không viết default khi switch trên sealed type. Giả sử team thêm kiểu Refunded vào permits nhưng quên cập nhật một chỗ switch:

Missing.java:8: error: the switch expression does not cover all possible input values
        return switch (result) {
               ^
1 error

Build fail ngay, chỉ đúng chỗ cần sửa. Nếu bạn viết default -> ..., lỗi này biến mất và Refunded sẽ lặng lẽ rơi vào nhánh mặc định lúc runtime. Trường hợp hiếm hơn: code switch được compile với phiên bản cũ của hierarchy rồi chạy cùng phiên bản mới (separate compilation) — khi đó JVM ném MatchException thay vì chạy sai.

Thứ tự case và "dominance"

Các case được thử lần lượt từ trên xuống. Nếu một case phía trên luôn khớp mọi thứ mà case phía dưới khớp, case phía dưới trở thành code chết — và compiler báo lỗi:

return switch (o) {
    case CharSequence cs -> "chuỗi ký tự";
    case String s -> "String"; // String là CharSequence: không bao giờ tới được
    default -> "khác";
};
Dominated.java:5: error: this case label is dominated by a preceding case label
            case String s -> "String";
                 ^

Quy tắc thực hành: case cụ thể đặt trước case tổng quát, case có guard đặt trước case cùng kiểu không có guard (như hai nhánh Declined ở trên).

Xử lý null

Theo truyền thống, switch ném NullPointerException khi giá trị là null, và điều đó vẫn đúng. Nhưng giờ bạn có thể xử lý null tường minh bằng case null:

static String format(Object obj) {
    return switch (obj) {
        case null -> "null";
        case Integer i when i > 1000 -> "số lớn " + i;
        case Integer i -> "số " + i;
        case String s -> "chuỗi \"" + s + "\"";
        default -> obj.getClass().getSimpleName();
    };
}
null | số 42 | số lớn 5000 | chuỗi "hi" | Double

Nếu muốn null đi chung nhánh mặc định, viết case null, default -> ....

Nested patterns: tối giản biểu thức toán học

Sức mạnh thật sự của record pattern là chúng lồng nhau được. Ví dụ kinh điển: một cây biểu thức số học.

sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr expr) implements Expr {}

static int eval(Expr e) {
    return switch (e) {
        case Num(var v) -> v;
        case Add(var l, var r) -> eval(l) + eval(r);
        case Mul(var l, var r) -> eval(l) * eval(r);
        case Neg(var x) -> -eval(x);
    };
}

Bây giờ viết hàm rút gọn biểu thức: cộng hai hằng số, nhân với 0 hoặc 1, phủ định hai lần:

static Expr simplify(Expr e) {
    return switch (e) {
        case Add(Num(var a), Num(var b)) -> new Num(a + b);
        case Mul(Num(var n), _) when n == 0 -> new Num(0);
        case Mul(_, Num(var n)) when n == 0 -> new Num(0);
        case Mul(Num(var n), var x) when n == 1 -> simplify(x);
        case Mul(var x, Num(var n)) when n == 1 -> simplify(x);
        case Neg(Neg(var x)) -> simplify(x);
        case Add(var l, var r) -> new Add(simplify(l), simplify(r));
        case Mul(var l, var r) -> new Mul(simplify(l), simplify(r));
        case Neg(var x) -> new Neg(simplify(x));
        case Num n -> n;
    };
}

Expr e = new Mul(new Num(1), new Neg(new Neg(new Add(new Num(2), new Num(3)))));
System.out.println(simplify(e));
System.out.println(eval(e));
Num[value=5]
5

Mỗi dòng case mô tả đúng một quy tắc rút gọn, đọc gần như ký hiệu toán học. Pattern Neg(Neg(var x)) khớp "phủ định của một phủ định" chỉ trong một biểu thức. Viết bằng instanceof và cast thủ công, cùng quy tắc đó cần 4–5 dòng lồng nhau.

So với Visitor pattern thì sao?

Trước Java 21, cách "chuẩn" để thêm thao tác cho một hierarchy mà không sửa từng class là Visitor pattern: một interface ExprVisitor<R> với một method cho mỗi kiểu, method accept trong mỗi class, cơ chế double dispatch. Pattern matching giải quyết cùng bài toán nhưng:

  • Ít code hơn nhiều: mỗi thao tác chỉ là một hàm với một switch.
  • Khớp được cấu trúc lồng nhau: Visitor chỉ dispatch trên một tầng; quy tắc như Add(Num, Num) phải tự kiểm tra bằng tay.
  • Vẫn an toàn: exhaustiveness đảm bảo không bỏ sót kiểu nào, giống như interface Visitor buộc implement đủ method.

Đánh đổi vẫn là "expression problem" quen thuộc: thêm thao tác mới rất dễ (thêm một hàm), còn thêm kiểu mới thì phải sửa mọi switch — nhưng compiler chỉ cho bạn chính xác những chỗ đó.

Nguyên tắc của data-oriented programming

1. Làm cho trạng thái không hợp lệ không thể biểu diễn được

So sánh cách mô hình hóa cũ:

// ❌ Trạng thái không hợp lệ vẫn biểu diễn được
public class PaymentResponse {
    private String status;          // "SUCCESS" | "DECLINED" | "REQUIRES_ACTION"
    private String transactionId;   // chỉ có ý nghĩa khi SUCCESS
    private String declineReason;   // chỉ có ý nghĩa khi DECLINED
    private URI redirectUrl;        // chỉ có ý nghĩa khi REQUIRES_ACTION
    // getters, setters...
}

Không gì ngăn một object có status = "SUCCESS" nhưng transactionId = null và declineReason = "...". Mọi chỗ dùng đều phải tự nhớ field nào hợp lệ với status nào. Với sealed hierarchy, mỗi trạng thái chỉ mang đúng dữ liệu của nó; trạng thái mâu thuẫn không thể được tạo ra.

2. Dữ liệu bất biến, validate tại biên

Validate một lần trong compact constructor. Phần còn lại của hệ thống được phép tin rằng object luôn hợp lệ, không cần kiểm tra lại ở mọi nơi.

3. Tách dữ liệu khỏi thao tác

Logic nghiệp vụ nằm trong các hàm dùng switch trên dữ liệu, thay vì rải vào method của từng class. Thêm một thao tác mới (xuất báo cáo, gửi thông báo, ghi log) không cần chạm vào định nghĩa kiểu.

Khi nào không nên dùng

  • Object có vòng đời và trạng thái thay đổi (entity, aggregate trong DDD có nhiều hành vi): class thông thường vẫn phù hợp hơn.
  • Hierarchy cần mở cho bên ngoài mở rộng (plugin, SPI): đừng seal nó.
  • Hành vi thực sự thuộc về chính đối tượng, như Shape.area() trong một thư viện hình học ổn định: phương thức ảo (polymorphism) vẫn là lựa chọn tự nhiên. Pattern matching tỏa sáng khi thao tác thay đổi thường xuyên hơn tập kiểu.

Kết luận

Records, sealed interfaces và pattern matching không chỉ là cú pháp gọn hơn. Chúng cho phép bạn mã hóa các quy tắc nghiệp vụ vào hệ thống kiểu, để compiler — chứ không phải code review hay bug trên production — phát hiện chỗ bị bỏ sót. Nếu dự án của bạn đã lên Java 21 hoặc 25, hãy bắt đầu từ chỗ đau nhất: một class với nhiều field nullable và một field status. Đó gần như luôn là một sealed hierarchy đang chờ được viết ra.

# Java
# Java 25
# Records
# Pattern Matching
# Backend