Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

java records

Tung Bui Thanh (TungDaDev)12 mins read

Java record.webp

Nhiều người coi Java Record chỉ là món thay Lombok (@Value / @Getter) cho bớt gõ vài dòng. Nhìn vậy thì hơi hẹp. Record là một thay đổi về cách thiết kế của chính ngôn ngữ Java: nó là cái "vỏ trong suốt" (transparent carrier) cho dữ liệu bất biến, đổi luôn cách JVM làm serialization, và mở đường cho kiểu dữ liệu đại số (Algebraic Data Types) đi cùng Pattern Matching.

Từ khi chính thức có mặt ở Java 16 (JEP 395), Record đã thành một viên gạch cơ bản của code Java hiện đại. Nhưng dùng mà không hiểu, ví dụ nhầm bất biến nông (shallow immutability) với bất biến sâu (deep immutability), hay cố đem Record làm JPA Entity, thì sẽ gặp lỗi rò rỉ dữ liệu hoặc mấy cái runtime exception khó hiểu.

Mình sẽ đi vào cách Record chạy bên dưới, để biết chỗ nào nên dùng, chỗ nào không.


# cam kết về ngữ nghĩa: transparent carrier là gì?

Brian Goetz (Java Language Architect ở Oracle) giải thích rằng mục tiêu của Record không phải "bớt boilerplate", bớt code chỉ là hệ quả. Record là khai báo về ngữ nghĩa: class này chỉ là một bộ dữ liệu, trạng thái của nó chính là các component, không hơn không kém. Vì lời cam kết đó mà compiler mới dám tự sinh constructor, accessor, equals, hashCode, toString, và serialization mới dám dựng lại object qua constructor:

# mấy luật cứng JVM đặt cho Record

  1. Implicitly Final & Abstract Superclass: record nào cũng ngầm kế thừa java.lang.Record và là final. Không ai kế thừa được record, và record cũng không kế thừa được class nào khác (nhưng implement interface thì thoải mái).
  2. Immutable Component Fields: mọi component field đều là private final.
  3. No Hidden State: không được khai báo thêm instance fields nào ngoài các component đã ghi trên header (field static thì được).
  4. Canonical Constructor: luôn có một constructor nhận đúng các tham số theo danh sách components.

# nông vs sâu: cạm bẫy shallow immutability

Có một hiểu lầm rất hay gặp: "Dùng Record là dữ liệu bất biến 100%!".

Thật ra Java Records chỉ bảo đảm bất biến nông (Shallow Immutability). Tham chiếu (reference) thì final, nhưng bản thân object được trỏ tới vẫn bị sửa (mutate) như thường nếu nó là mutable!

Xem đoạn code làm lộ state ra ngoài dưới đây:

// NGUY HIỂM: Rò rỉ trạng thái nội tại!
public record SecurityContext(String username, List<String> roles) {}
 
// Hacker hoặc code ngoài luồng có thể sửa đổi:
List<String> roles = new ArrayList<>(List.of("USER"));
SecurityContext ctx = new SecurityContext("alice", roles);
 
// BÊN NGOÀI THAY ĐỔI:
roles.add("ADMIN"); // SecurityContext bị thay đổi mà không hề hay biết!
 
// HOẶC TRUY XUẤT ĐỂ SỬA:
ctx.roles().clear(); // Toàn bộ quyền bị xóa sạch!

# cách xử lý: defensive copying trong compact constructor

Muốn bất biến thật thì phải copy phòng thủ (defensive copy) trong compact constructor:

public record SecurityContext(String username, List<String> roles) {
 
    // Compact Constructor: Chuẩn hóa và bảo vệ trạng thái khi khởi tạo
    public SecurityContext {
        Objects.requireNonNull(username, "Username không được null");
        // Ép sang Unmodifiable List để triệt tiêu mọi hành vi mutation từ bên ngoài
        roles = (roles == null) ? List.of() : List.copyOf(roles);
    }
}

List.copyOf làm hai việc: tạo bản sao (bên ngoài sửa list gốc cũng không ảnh hưởng) và trả về list không sửa được (ctx.roles().clear() ném UnsupportedOperationException). Vì thế không cần override accessor roles() nữa. Chỉ khi bạn buộc phải giữ một collection mutable bên trong thì mới cần override accessor để trả bản sao ra ngoài. Hai điều cần nhớ: List.copyOf ném NullPointerException nếu list có phần tử null, và nó chỉ khoá cái list chứ không khoá phần tử, list chứa object mutable thì phần tử vẫn bị sửa được. String vốn bất biến nên ví dụ này an toàn.


# đổi hẳn chuyện bảo mật: Java deserialization defense

Lỗ hổng Java Deserialization Vulnerability (qua ObjectInputStream.readObject()) từng làm khổ không biết bao nhiêu hệ thống doanh nghiệp.

# tại sao POJO truyền thống dễ bị tấn công qua serialization?

Với class bình thường, khi Java deserialize một chuỗi byte độc hại:

  • JVM tạo object bằng một constructor đặc biệt sinh ra qua ReflectionFactory: nó chỉ chạy constructor không tham số của class cha đầu tiên không Serializable (thường là Object), rồi gán thẳng giá trị đọc từ stream vào các field, kể cả field private final.
  • Constructor của chính class đó bị bỏ qua hoàn toàn. Mọi logic validate (if (amount <= 0) throw new IllegalArgumentException()) bạn viết trong constructor coi như không tồn tại, trừ khi bạn tự viết lại nó trong readObject.

# cơ chế phòng thủ tự nhiên của Record

Với Record, JVM deserialize theo cách khác hẳn:

  1. JVM đọc các stream field ra thành từng giá trị riêng.
  2. JVM bắt buộc đưa các giá trị đó qua canonical constructor của Record. Các hook như readObject, readObjectNoData trên record bị bỏ qua, nên cũng không ai chèn được đường tắt.
  3. Validate trong constructor ném exception thì readObject() ném InvalidObjectException. Cứ thử sửa tay byte trong stream của một record Money(long amount) có validate amount > 0 để amount thành số âm là thấy: deserialize bị chặn ngay.

Nhưng đừng hiểu thành "dùng Record là hết lo deserialization". Record chỉ bảo vệ bất biến của chính nó. Các đòn tấn công kiểu gadget chain (ghép những class có sẵn trên classpath như Commons Collections để chạy code) vẫn dùng được nếu ứng dụng còn deserialize dữ liệu không tin cậy bằng ObjectInputStream. Cách phòng chính vẫn là không nhận Java serialization từ ngoài, hoặc ít nhất cấu hình ObjectInputFilter (JEP 290) để chỉ cho phép đúng các class cần thiết.


# hệ thống kiểu đại số: sealed interfaces & pattern matching

Ghép Records với Sealed Interfaces là Java có được Algebraic Data Types (ADT), món vốn là đặc sản của mấy ngôn ngữ hàm như Haskell hay Scala:

# khai báo model nghiệp vụ cho chặt

public sealed interface OrderPaymentResult permits
    OrderPaymentResult.Success,
    OrderPaymentResult.Declined,
    OrderPaymentResult.PendingReview {
 
    record Success(String transactionId, BigDecimal amount) implements OrderPaymentResult {}
    record Declined(String reasonCode, String message) implements OrderPaymentResult {}
    record PendingReview(String verificationUrl, Instant timeoutAt) implements OrderPaymentResult {}
}

# tận dụng pattern matching trong Java 21+

Từ Java 21, pattern matching cho switch (JEP 441) và record pattern (JEP 440) đã chính thức. Với sealed interface, compiler kiểm tra được đủ case hay chưa (exhaustiveness checking): quên xử lý một trường hợp con là code không compile:

public class PaymentProcessingEngine {
 
    public String handlePaymentOutcome(OrderPaymentResult result) {
        // Record Pattern Deconstruction kết hợp với Pattern Matching Switch
        return switch (result) {
            case OrderPaymentResult.Success(var txId, var amount) ->
                "Giao dịch " + txId + " thành công số tiền: " + amount;
 
            case OrderPaymentResult.Declined(var code, var msg) ->
                "Từ chối thanh toán [" + code + "]: " + msg;
 
            case OrderPaymentResult.PendingReview(var url, var expire) ->
                "Cần xác minh bảo mật trước " + expire + " tại: " + url;
            // Không cần nhánh 'default:'! Trình biên dịch đảm bảo 100% không sót case.
        };
    }
}

Cái lợi lớn nhất của việc bỏ default là khi sau này thêm một kiểu kết quả mới vào permits, mọi switch chưa xử lý nó sẽ báo lỗi compile ngay, thay vì lặng lẽ rơi vào nhánh default lúc runtime. Nếu class được compile riêng rồi gặp một subtype lạ lúc chạy, JVM ném MatchException.


# cái bẫy lớn: tại sao Record không làm JPA Entity được?

Nhiều người hứng lên muốn thay hết @Entity bằng record. Đừng làm vậy, sai từ gốc về thiết kế.

# vì sao Record đụng nhau với JPA Entity?

  1. Spec của JPA: entity phải có constructor không tham số và không được là class final. Record vi phạm cả hai ngay từ đầu.
  2. Hibernate Proxy Generation: quan hệ FetchType.LAZY thì Hibernate sinh một subclass động (proxy) kế thừa entity để chặn các getter. Record là final nên không tạo proxy được.
  3. Dirty Checking: entity được nạp lên rồi bạn sửa field, Hibernate so với bản chụp (snapshot) lúc nạp để biết cần UPDATE gì. Record không sửa được field nào thì cơ chế này không còn ý nghĩa, và một entity đã được quản lý (managed) phải là cùng một object suốt phiên, không thể thay bằng bản sao mới.

Riêng @Embeddable thì khác: từ Hibernate 6.2, record dùng làm embeddable được (ví dụ value object Money(BigDecimal amount, String currency) nhúng vào entity), vì embeddable không cần proxy và được thay nguyên cả cục khi đổi giá trị.

# cách dùng chuẩn: Record làm DTO projection & query result

Record không làm Entity được, nhưng làm DTO Projection trong Spring Data JPA thì rất hợp:

// DTO Projection siêu nhẹ, bỏ qua hoàn toàn chi phí quản lý trạng thái của First-Level Cache
public record CustomerSummaryDTO(Long id, String fullName, String email, Long totalOrders) {}
 
public interface CustomerRepository extends JpaRepository<Customer, Long> {
 
    // Constructor Expression: Ánh xạ thẳng kết quả SQL vào Record
    @Query("""
        SELECT new com.enterprise.dto.CustomerSummaryDTO(
            c.id, c.fullName, c.email, COUNT(o.id)
        )
        FROM Customer c
        LEFT JOIN c.orders o
        GROUP BY c.id, c.fullName, c.email
    """)
    List<CustomerSummaryDTO> findAllCustomerSummaries();
}

# tối ưu hóa jackson & serialization trong REST API

Dùng Record làm Request/Response DTO trong Spring Boot REST API thì Jackson 2.12+ hỗ trợ sẵn, khỏi cần thêm annotation lỉnh kỉnh nào:

public record CreateUserRequest(
    @NotBlank(message = "Tên đăng nhập không được để trống")
    @Size(min = 4, max = 32)
    String username,
 
    @Email(message = "Email không đúng định dạng chuẩn RFC")
    String email,
 
    @NotNull(message = "Hạn mức tín dụng là bắt buộc")
    @DecimalMin(value = "0.0")
    BigDecimal creditLimit
) {}

Jackson tự lấy các tham số từ JSON rồi đưa thẳng vào canonical constructor. Annotation đặt trên record component được Java truyền xuống field, tham số constructor và accessor tương ứng, nên chỉ cần @Valid @RequestBody CreateUserRequest request ở Controller là Jakarta Bean Validation chạy luôn.


# checklist trước khi lên production (architect's checklist)

  • 1. Deep Immutability: các component kiểu Collection (List, Set, Map) trong Record đã được copy bằng List.copyOf() / Set.copyOf() / Map.copyOf() trong Compact Constructor chưa? Collections.unmodifiableList() chỉ là một lớp vỏ nhìn vào list gốc, bên ngoài sửa list gốc thì record vẫn đổi theo, nên một mình nó là chưa đủ.
  • 2. Không dùng làm JPA Entity: đừng gắn @Entity lên record nào hết. Record hợp với DTO, Event Message, Value Object (kể cả @Embeddable từ Hibernate 6.2) hoặc Projection.
  • 3. Đừng clone / mutate lung tung: cần đổi vài component của Record thì dùng "Wither Pattern" (withAmount(...) trả về instance mới) cho có quy củ.
  • 4. Tận dụng Pattern Matching: dùng sealed interface kết hợp record thay cho mấy chuỗi if-else instanceof rối rắm.
  • 5. Đừng lo GC quá sớm: record là object sống ngắn, mà GC thế hệ (G1 mặc định, Generational ZGC) dọn object sống ngắn ở vùng Eden rất rẻ, JIT còn có thể bỏ hẳn việc cấp phát nhờ escape analysis. Chỉ khi profiler (JFR, async-profiler) cho thấy allocation là nút thắt thật thì mới tính chuyện tối ưu.

# lời kết

Record gọn thật, nhưng điều đáng giá hơn là lời cam kết đi kèm: dữ liệu là toàn bộ trạng thái, và mọi đường tạo object đều phải đi qua constructor. Đặt đúng chỗ, như làm Value Object trong DDD, DTO trong REST/gRPC API, hay Event Payload trong Kafka, thì Record giúp code dễ đọc và ít bất ngờ hơn. Muốn đọc kỹ hơn thì xem JEP 395 và bài Data Classes and Sealed Types for Java của Brian Goetz.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.