Java — Từ Zero đến Senior/OOP cơ bản — class, object, encapsulation/Record — data class với 1 dòng
6/7
~15 phútOOP cơ bản — class, object, encapsulation

Record — data class với 1 dòng

Record từ Java 16: cú pháp, canonical constructor, compact constructor, implicit accessor, khi nào nên dùng record thay class truyền thống, và giới hạn (immutable, không kế thừa).

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ườngJava
Biểu mẫu 1 dòngrecord Book(String title, int year) {}
Các ô trong biểu mẫuComponent (title, year)
Máy in tự điền thông tin khácCompiler 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:

  1. Private final field cho mỗi component: private final String title; v.v.
  2. Canonical constructor Book(String, String, int) gán tất cả field.
  3. Accessor title(), author(), year()không phải getTitle().
  4. equals(Object) so dựa trên mọi component.
  5. hashCode() hash dựa trên mọi component.
  6. 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ải public 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:

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

Tự kiểm tra
Q1
Viế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 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;
    }
}

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
}
Q3
Record 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());

Không — "shallowly immutable". In ra [apple, banana, cherry].

Field itemsfinal — 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.

Q4
Khi 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 Connection giữ 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ự.

Q5
Record 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 ERROR

Lý 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