Trong hệ sinh thái Java doanh nghiệp, Project Lombok vừa là thư viện rất phổ biến, vừa là thứ gây tranh cãi nhiều trong giới dev. Một mặt, Lombok đỡ cho bạn hàng nghìn dòng boilerplate (getter, setter, constructor, equals/hashCode). Mặt khác, sự tiện đó có giá: phá encapsulation, sinh lỗi ngầm ở tầng ORM/JPA, và làm ngoại lệ lọt ra ngoài tầm kiểm soát.
Nếu bạn là người chịu trách nhiệm về kiến trúc, đừng chỉ nhìn Lombok như thứ "giúp gõ code nhanh hơn". Bài này nói về cách Lombok chạy bên trong trình biên dịch, những lỗi kinh điển có thể làm sập production, và vài quy tắc để kiểm soát nó.
# Lombok hoạt động thế nào bên trong javac?
Nhiều người nghĩ Lombok là một Annotation Processor tiêu chuẩn của Java. Thực tế không phải vậy.
JSR 269 (Pluggable Annotation Processing API) chỉ cho annotation processor đọc mã nguồn và tạo thêm file source code mới lúc biên dịch (như MapStruct hay Google AutoValue). JSR 269 không cho sửa trực tiếp các class đang được biên dịch.
Để lách giới hạn này, Lombok dùng reflection nội bộ, ép kiểu Trees của javac sang com.sun.tools.javac.tree.JCTree. Sau đó Lombok sửa trực tiếp cây cú pháp trừu tượng (Abstract Syntax Tree, AST) trong bộ nhớ, trước khi trình biên dịch xuất bytecode .class.
WARNING
Vì Lombok phụ thuộc vào các API nội bộ chưa chuẩn hóa của javac (internal non-public APIs), mỗi khi OpenJDK ra phiên bản mới hoặc siết đóng gói module (JEP 396/403 Strong Encapsulation of JDK Internals), dự án dùng Lombok hay bị vỡ build cho tới khi Lombok ra bản mới tương thích. Ví dụ khi JDK 16 ra mắt, phải chờ Lombok 1.18.20 (4/2021) mới build được. Vì vậy nâng JDK thì nhớ nâng Lombok đi kèm.
# những cái bẫy trên production khi lạm dụng Lombok
# sử dụng @data trên JPA / Hibernate Entity
Đây là sai lầm phổ biến nhất, dẫn tới lỗi khó lần, query thừa, thậm chí treo ứng dụng.
Khi gắn @Data lên một Hibernate Entity, Lombok tự động sinh ra:
equals() và hashCode() dựa trên tất cả các trường của class.
toString() duyệt qua tất cả các trường, bao gồm cả các quan hệ @OneToMany hoặc @ManyToOne.
Hậu quả trên Production:
Vỡ Hashing Contract trong Java Collections: Khi lưu Entity vào Set hoặc làm key trong Map, nếu khóa chính id do Database sinh (Identity), hashCode() của entity sẽ khác nhau trước và sau persist(). Entity "biến mất" khỏi Set dù vẫn còn trong bộ nhớ.
N+1 và LazyInitializationException do toString(): Khi ghi log hoặc debug kiểu log.info("Loaded order: {}", order);, toString() của @Data âm thầm gọi getItems(). Hậu quả là hàng trăm câu SQL phụ (N+1 Query), hoặc LazyInitializationException nếu Session đã đóng.
StackOverflowError trong quan hệ hai chiều: Nếu Order trỏ tới OrderItem và OrderItem trỏ ngược lại Order, toString() hoặc equals() sẽ lặp vô hạn giữa hai entity cho đến khi đầy Call Stack.
Quy tắc cứng: KHÔNG DÙNG @Data CHO JPA ENTITIES. Dùng @Getter, @Setter có kiểm soát, và tự cài đặt equals()/hashCode() dựa trên khóa nghiệp vụ duy nhất (Business Key) hoặc khóa chính.
# @equalsandhashcode(callsuper = false) trong cây phân cấp kế thừa
Mặc định, khi một class con kế thừa class cha và gắn @EqualsAndHashCode, Lombok đặt callSuper = false (và in cảnh báo lúc biên dịch, nhưng cảnh báo thì hay bị lờ đi).
@Getter@Setterpublic class BaseEntity { private Long id; private Instant createdAt;}@Getter@Setter@EqualsAndHashCode // Nguy hiểm: callSuper mặc định là false!public class AccountUser extends BaseEntity { private String email;}
Nếu hệ thống có 2 đối tượng AccountUser:
User A: id = 1, email = "admin@tungdadev.com"
User B: id = 2, email = "admin@tungdadev.com"
Lệnh userA.equals(userB) sẽ trả về true vì Lombok bỏ qua hết các trường của BaseEntity. Hai user khác nhau bị coi là một: bỏ vào Set thì mất một người, dùng làm key cache thì có thể lấy nhầm dữ liệu của người kia. Ở hệ thống phân quyền hay ngân hàng, đó là loại lỗi rất khó phát hiện.
Sửa bằng callSuper = true cũng chưa xong: nếu BaseEntity không tự có equals(), lời gọi super.equals() rơi về Object.equals() vốn so sánh địa chỉ, nên hai object có dữ liệu y hệt nhau cũng ra false. Muốn callSuper = true có nghĩa thì class cha cũng phải có equals/hashCode đàng hoàng.
# @Builder.default và Jackson
Khi dùng @Builder, nếu bạn đặt giá trị mặc định cho trường:
@Builder@Getterpublic class RetryPolicy { @Builder.Default private int maxRetries = 3; @Builder.Default private Duration backoff = Duration.ofSeconds(2);}
Jackson không tự dùng Builder của Lombok. Class trên chỉ có constructor all-args (package-private) do @Builder sinh ra, nên nhận request JSON vào Controller là lỗi ngay: Cannot construct instance of RetryPolicy... no delegate- or property-based Creator.
Cách vá hay gặp là thêm @NoArgsConstructor + @AllArgsConstructor để Jackson gọi constructor rỗng rồi set field. Ở Lombok cũ (trước 1.18.2), @Builder.Default chuyển giá trị khởi tạo vào trong builder, nên constructor rỗng tạo ra object với:
maxRetries bằng 0 (giá trị mặc định của primitive int).
backoff bằng null.
Logic retry bị vô hiệu hóa âm thầm, không có cảnh báo biên dịch nào. Từ Lombok 1.18.2, @NoArgsConstructor đã tính tới @Builder.Default nên lỗi này không còn, nhưng nó vẫn hay được nhắc lại vì nhiều project còn kẹt bản cũ.
Cách sạch nhất hiện nay là thêm @Jacksonized (Lombok 1.18.14+) cạnh @Builder: Jackson sẽ deserialize qua chính builder, field nào JSON không gửi thì lấy giá trị @Builder.Default, và class vẫn giữ được immutable.
# @sneakythrows làm vỡ mô hình xử lý ngoại lệ
@SneakyThrows cho phép ném các Checked Exception (như SQLException, IOException) mà không cần khai báo throws trong chữ ký phương thức:
Lombok làm thế nào: Trong bytecode, JVM không phân biệt Checked hay Unchecked Exception; quy tắc throws chỉ do javac áp đặt. Lombok bọc thân method trong try/catch (Throwable t) rồi ném lại qua Lombok.sneakyThrow(t), một generic method dạng <T extends Throwable> void sneakyThrow0(Throwable t) throws T. Do type erasure, javac tưởng T là RuntimeException nên không bắt khai báo throws, còn lúc chạy thì exception gốc vẫn được ném nguyên vẹn.
Vì sao đây là Anti-Pattern?
Vỡ tầng trừu tượng: Service ném SQLException mà Controller bên trên không biết để catch, vì Controller chỉ đợi các Unchecked Business Exception. Thậm chí muốn catch cũng không được: viết catch (SQLException e) quanh lời gọi method đó là javac báo lỗi "exception is never thrown in body of corresponding try statement", chỉ còn cách catch Exception chung chung.
Transaction không rollback: Spring @Transactional mặc định chỉ rollback khi gặp RuntimeException hoặc Error. Checked exception lọt ra từ method @SneakyThrows sẽ khiến transaction vẫn commit, dữ liệu ghi dở nằm lại trong database, trừ khi bạn khai báo rollbackFor.
# quy tắc dùng Lombok trong dự án
Để an toàn hơn, nên đặt ra một bảng quy tắc dùng Lombok (Lombok Governance Matrix) cho cả team:
Tự implement equals/hashCode theo Business Key; dùng Explicit Factory methods
Data Transfer Objects (DTO)
@Getter, @Builder, @Jacksonized
@Data (nếu có kế thừa)
Ưu tiên Java 16+ Records
Value Objects (DDD)
@Value (Immutable)
@Data, @Setter
Java Records
Spring Services / Components
@RequiredArgsConstructor (Constructor Injection)
@Autowired field injection
Constructor Injection thuần túy
Utility Classes
@UtilityClass
Khởi tạo thủ công
final class với private constructor
Error / Exception Handling
Không khuyến khích
@SneakyThrows
Khai báo throws rõ ràng hoặc bọc vào BusinessException
# thay Lombok bằng Java Records & MapStruct
Từ Java 16, Java có sẵn Records: một kiểu class chuyên chở dữ liệu, bất biến, được ngôn ngữ hỗ trợ trực tiếp. Kết hợp với MapStruct, phần lớn chỗ đang dùng Lombok cho DTO có thể bỏ được (entity JPA thì không dùng record được, vì Hibernate cần constructor rỗng và field sửa được):
# so sánh DTO bằng Lombok vs Java Record
// CÁCH CŨ DÙNG LOMBOK: Cần 5 annotations, phụ thuộc vào javac AST hack@Data@Builder@NoArgsConstructor@AllArgsConstructor@Jacksonizedpublic class UserResponseDto { private Long id; private String username; private String email;}// CÁCH HIỆN ĐẠI DÙNG JAVA RECORD: Gọn gàng, bất biến, 100% Native JVM Supportpublic record UserResponseDto( Long id, String username, String email) { // Compact constructor để validate dữ liệu nếu cần public UserResponseDto { Objects.requireNonNull(username, "Username must not be null"); Objects.requireNonNull(email, "Email must not be null"); }}
Java Records có gì hơn:
Field final thật sự: field của record không sửa được kể cả bằng reflection, HotSpot dựa vào đó để tối ưu mạnh hơn. Còn về bộ nhớ, record không nhỏ hơn một class thường có cùng các field final; đừng kỳ vọng tiết kiệm RAM.
Serialization Safety: Khi deserialize (Java Serialization), record luôn được tạo qua canonical constructor, nên validation trong compact constructor luôn chạy, không thể dựng ra một object "lách" qua kiểm tra như với class thường.
Pattern Matching: Dùng được với Pattern Matching for switch và Record Patterns trong Java 21+.
# kết luận
Lombok không phải "thuốc độc", nhưng là con dao hai lưỡi. Ở dự án lớn, cứ để mọi người tự do gắn @Data hay @SneakyThrows mà không hiểu cơ chế biên dịch thì rất nhanh tích nợ kỹ thuật (technical debt).
Nếu bạn là người định hướng kỹ thuật, nên:
Tạo file lombok.config ở thư mục gốc repository để chặn các annotation nguy hiểm: