Bài này về một câu hỏi đơn giản: khi chỉ cần đọc, có nên lấy cả entity không.

Ba cách, cùng số truy vấn

  DTO constructor expression   -> 20 kết quả, 1 truy vấn, vd=TenVaSo[ten=Tac gia 0, so=5]
  interface projection         -> 20 kết quả, 1 truy vấn, kiểu=proxy
  findAll() lấy cả entity      -> 20 kết quả, 1 truy vấn

Cả ba đều một truy vấn. Nên khác biệt không nằm ở số lần đi CSDL.

Nó nằm ở ba thứ mà entity mang theo mà projection thì không.

Ba thứ entity mang theo

Nó được quản lý. Entity nằm trong persistence context, nên mọi thay đổi bạn vô tình gán vào nó sẽ được Hibernate phát hiện và ghi xuống CSDL lúc commit — kể cả khi bạn không gọi save. Đây là nguồn của những câu UPDATE xuất hiện trong log mà không ai giải thích được.

Nó có quan hệ lazy. Bài 24 và 25 đã đo cả hai hậu quả: N+1, và LazyInitializationException khi serialize.

Nó tốn bộ nhớ. Hibernate giữ một bản chụp của mọi entity được quản lý để so sánh khi dirty checking. Nghĩa là gấp đôi bộ nhớ cho mỗi entity đọc lên. Trên 10.000 bản ghi để hiển thị danh sách, đó là chi phí thuần lãng phí.

Projection không có cả ba.

Cách một: interface projection

interface ChiTen { String getTen(); }
List<ChiTen> findByTenNotNull();

Spring sinh proxy cho giao diện, và chỉ chọn cột ten trong SQL.

Gọn nhất, không cần lớp mới. Ghép được với quan hệ bằng cú pháp lồng:

interface BaiTomTat {
    String getTieuDe();
    TacGiaTomTat getTacGia();
    interface TacGiaTomTat { String getTen(); }
}

Và với biểu thức SpEL cho trường tính toán:

interface Tom {
    @Value("#{target.ten + ' (' + target.bais.size() + ')'}")
    String getMoTa();
}

Cẩn thận: biểu thức SpEL kiểu này gọi vào target, nghĩa là Spring phải lấy entity đầy đủ — bạn mất hết lợi ích. Nó gọi là "open projection", và nó âm thầm biến projection thành entity.

Muốn chắc chắn chỉ lấy cột cần thì đừng dùng @Value.

Cách hai: constructor expression

record TenVaSo(String ten, int so) {}

@Query("select new vd.jpa.TenVaSo(t.ten, size(t.bais)) from TacGia t")
List<TenVaSo> thongKe();
  TenVaSo[ten=Tac gia 0, so=5]

Rõ ràng nhất: bạn thấy chính xác cột nào được lấy, và kết quả là một record thật, không phải proxy.

Nó cũng là cách duy nhất trong ba cách làm được giá trị tính toánsize(), count(), sum(), biểu thức case.

Ba lưu ý:

Phải ghi tên lớp đầy đủ kể cả package. Đổi package là gãy lúc chạy, không phải lúc biên dịch.

Thứ tự tham số phải khớp constructor. Hai trường cùng kiểu đặt nhầm thứ tự thì không có lỗi nào cả — chỉ có dữ liệu sai chỗ.

Hibernate 6 nới lỏng cú pháp — bỏ được new và tên lớp trong nhiều trường hợp, nhưng viết đủ thì tường minh hơn.

Cách ba: DTO ánh xạ trong service

@Transactional(readOnly = true)
public List<TacGiaDto> ds() {
    return repo.findAll().stream().map(TacGiaDto::tu).toList();
}

Vẫn tải entity lên, nên vẫn trả tiền cho cả ba thứ ở trên. Nhưng nó có ưu điểm riêng: entity không ra khỏi giao dịch, và bạn ánh xạ được logic phức tạp bằng Java.

Đây là cách tôi dùng khi cần dữ liệu từ nhiều nguồn hoặc cần định dạng lại nhiều. Với danh sách đơn thuần thì hai cách trên tốt hơn.

Chọn cái nào

Tình huống Dùng
Danh sách, chỉ vài cột interface projection
Có giá trị tính toán, gộp nhóm constructor expression
Chỉ đọc, dữ liệu lớn một trong hai cái trên
Cần sửa rồi lưu entity
Logic ánh xạ phức tạp entity + DTO trong service
Báo cáo nhiều bảng SQL thuần (bài 31)

Quy tắc gọn: đọc thì projection, ghi thì entity.

Đừng dùng entity làm DTO của API

Nhắc lại từ bài 15, vì nó là lỗi kiến trúc tốn kém nhất trong chặng này. Bốn hậu quả:

vòng lặp tham chiếu khi serialize; LazyInitializationException; N+1 phát sinh lúc Jackson duyệt quan hệ; và lộ dữ liệu — thêm một cột vào entity là nó tự xuất hiện trong API.

Cái thứ tư nguy hiểm nhất vì nó im lặng. Không có lỗi, không có test đỏ, chỉ có một trường mới nằm trong phản hồi công khai.

DTO tốn thêm mã. Nhưng nó tách hợp đồng API khỏi lược đồ CSDL, và hai thứ đó phải đổi độc lập được — đó là toàn bộ lý do.

Về thư viện ánh xạ

MapStruct sinh mã ánh xạ lúc biên dịch: nhanh, không dùng phản chiếu, và sai tên trường là lỗi biên dịch.

ModelMapper dùng phản chiếu lúc chạy: ít mã hơn, nhưng sai tên trường thì im lặng để null, và nó chậm hơn đáng kể.

Với DTO ít, viết tay là đủ và rõ nhất. Với dự án lớn, MapStruct đáng bỏ công cấu hình.

Thử ba mươi giây

grep -rn "public.*Entity.*(" --include='*Controller.java' src/main

Mỗi controller trả về kiểu entity là một chỗ lược đồ CSDL của bạn đang là hợp đồng API công khai. Đổi một cột là đổi API, và không có gì nhắc bạn điều đó.

Ngày mai: khi JPA không phải công cụ đúng — JdbcClient và SQL thuần.