Libraries & Ecosystem · کتابخانهها و اکوسیستم پایهBeginner ~38 دقیقه مطالعه~33 min read
Lombok: کدِ کمتر، باگِ کمترLombok: Less Boilerplate, Fewer Bugs
از صفر تا سنیور با Lombok: چطور با چند اَنوتیشن دهها خط کدِ تکراری را حذف میکنی، این جادو زیر پوستِ کامپایلر چطور کار میکند، کجا خطرناک است (بهویژه با JPA) و کِی باید سراغش نروی.A zero-to-senior tour of Lombok: how a handful of annotations erase dozens of lines of boilerplate, how the magic really works inside javac, where it bites (especially with JPA), and when you should skip it.
سلام. بیا از یک حقیقتِ ساده شروع کنیم که هر کسی چند ماه جاوا نوشته باشد با پوست و استخوان لمسش کرده: جاوا زبانِ پُرگویی است. برای اینکه فقط بگویی «یک کاربر، اسمی دارد و ایمیلی»، باید فیلدها را بنویسی، بعد getter و setter هرکدام را، بعد equals و hashCode و toString و یکی دو تا constructor. کلاسی که ایدهاش دو خط است، روی صفحه میشود پنجاه خط — و آن پنجاه خط نه فقط خستهکننده، بلکه خطرخیز است: کافی است در hashCode یک فیلد را از قلم بیندازی تا شبها با یک باگِ ساکت بجنگی.
Lombok دقیقاً برای درمانِ همین درد ساخته شد. در این فصل قرار نیست فهرستی از اَنوتیشنها را حفظ کنی؛ قرار است بفهمی هر اَنوتیشن چه کدی میسازد، این ساخت زیرِ پوستِ کامپایلر چطور اتفاق میافتد، کجا این جادو به دام تبدیل میشود، و در مصاحبهی سنیور چطور دربارهاش حرف بزنی که معلوم شود فقط «کاربر» نیستی، بلکه میدانی چه اتفاقی میافتد.
با هم این مسیر را میرویم:
- درد را بشناس — «boilerplate» یعنی چه و چرا فقط تنبلی نیست، بلکه منبعِ باگ است.
- Lombok را وصل کن — وابستگی Maven/Gradle، معنیِ scope و چرا به پلاگینِ IDE هم نیاز داری.
- جادو زیر پوست — Lombok یک annotation processor معمولی نیست؛ AST کامپایلر را دستکاری میکند. این تفاوت همهچیز است.
- اَنوتیشنهای اصلی —
@Getter/@Setter،@ToString،@EqualsAndHashCode،@Data، سازندهها،@Value،@Builder،@Slf4j. - ابزارهای ظریف —
val/var،@NonNull،@SneakyThrows،@Cleanup. - دامها — بهویژه فاجعهی
@Dataروی entity در JPA و راهحلِ درست. lombok.config، delombok، نقدها و مقایسه با record.- پرسشهای مصاحبه با پاسخ کامل، و یک جمعبندی.
بخش صفر — چند کلمه که پیش از شروع باید حسشان کنی
قبل از هر کد، سه اصطلاح هست که در کل فصل برمیگردند. بگذار همین حالا جا بیندازمشان.
- کدِ تکراری (boilerplate): کدی که باید بنویسی اما هیچ منطقِ تازهای در آن نیست — getter، setter، constructor. اسمش از ورقهای چاپیِ آمادهی روزنامهها میآید که فقط جای اسم را عوض میکردند. boilerplate بد است نه چون طولانی است، بلکه چون جای اشتباه است: هر خطِ دستنویس یک فرصت برای باگ است.
- اَنوتیشن (annotation): یک برچسبِ فلزی روی کد، مثل
@Override. خودش رفتاری ندارد؛ فقط یک یادداشت روی کلاس/فیلد/متد است که ابزارهای دیگر میتوانند بخوانند و بر اساسش تصمیم بگیرند. - بایتکد و AST: کامپایلر (
javac) کدِ تو را اول به یک درختِ ساختاری در حافظه تبدیل میکند به اسمِ AST (درختِ نحوِ انتزاعی)، بعد از روی آن درخت بایتکد (.class) میسازد که JVM اجرا میکند. این جمله را نگه دار؛ کلِ رازِ Lombok در همین «درخت» است.
تصور کن برای هر کاری در اداره باید یک فرمِ دهصفحهای پر کنی که نُه صفحهاش همیشه یکی است و فقط یک خطش فرق دارد. کارِ واقعیِ تو همان یک خط است؛ بقیه فقط زحمتِ تکراری و جای خطاست (یک خانه را اشتباه تیک بزنی، کلِ فرم رد میشود). Lombok مثل کارمندی است که آن نُه صفحهی همیشگی را خودش و بیغلط پر میکند و فقط آن یک خطِ مهم را به تو میسپارد.
چرا اصلاً Lombok؟ درد را با کد ببین
بیا یک کلاسِ دادهی کاملاً معمولی را دستی بنویسیم — یک کاربر با سه فیلد که «درست» نوشته شده باشد:
public class User {
private final Long id;
private String name;
private String email;
public User(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id)
&& Objects.equals(name, user.name)
&& Objects.equals(email, user.email);
}
@Override
public int hashCode() {
return Objects.hash(id, name, email);
}
@Override
public String toString() {
return "User{id=" + id + ", name='" + name + "', email='" + email + "'}";
}
}
نزدیک چهل خط، و ایده فقط این بود: «کاربری با سه فیلد». حالا همین را با Lombok:
import lombok.Data;
@Data
public class User {
private final Long id;
private String name;
private String email;
}
پنج خط. و نکتهی مهم این نیست که کوتاهتر است؛ نکته این است که آن نسخهی دستی قابلِ اشتباه بود و این نسخه نیست. اگر فردا فیلدِ phone اضافه کنی، در نسخهی دستی باید یادت بماند که آن را به equals و hashCode و toString و constructor هم اضافه کنی — و همین «یادت بماند» است که باگ میسازد. Lombok بهطور خودکار همه را همگام نگه میدارد.
Lombok طول کد را کم میکند، اما ارزشِ واقعیاش کمکردنِ باگ است: کدی که تو ننویسی، نمیتوانی خرابش کنی، و از فیلدها عقب نمیماند. عنوانِ فصل تصادفی نیست — «کدِ کمتر، باگِ کمتر».
Lombok را وصل کن
Lombok یک کتابخانهی معمولی نیست که در زمانِ اجرا صدایش بزنی؛ فقط در زمانِ کامپایل کار دارد. برای همین scope اش مهم است.
در Maven:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
<scope>provided</scope>
</dependency>
در Gradle مدرن، درستترین شکل این است که هم بهعنوان annotationProcessor و هم compileOnly معرفی شود:
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.46'
annotationProcessor 'org.projectlombok:lombok:1.18.46'
}
چون Lombok در زمانِ اجرا هیچکاری نمیکند. کارش این است که سرِ کامپایل کد بسازد؛ بعد از آن، خروجی یک .class کاملاً معمولی است که هیچ ردی از Lombok در آن نیست. پس نباید Lombok را داخلِ jarِ نهایی و کلاسپثِ زمانِ اجرا بفرستی. provided (Maven) و compileOnly (Gradle) دقیقاً یعنی «سرِ کامپایل باش، سرِ اجرا نباش». نسخهی پایدار در زمانِ نگارشِ این فصل 1.18.46 (آوریل ۲۰۲۶) است که از JDK 24 و 25 هم پشتیبانی میکند.
یک نکته که تازهکارها را دیوانه میکند: کامپایلر خطِ فرمان (mvn/gradle) کد را درست میسازد، اما IDE قرمز خط میکشد و میگوید «getName وجود ندارد». چرا؟ چون IDE کدِ ساختهشدهی Lombok را نمیبیند مگر اینکه پلاگینش را داشته باشد. در IntelliJ IDEA نسخههای جدید پلاگین Lombok بهصورت باندلشده هست و فقط باید Enable annotation processing را روشن کنی؛ در Eclipse باید یک بار jarِ Lombok را روی نصبِ Eclipse اجرا کنی تا خودش را «تزریق» کند. بدونِ این، تجربهی توسعه شکنجه است.
جادو زیرِ پوست: Lombok واقعاً چطور کار میکند؟
اینجا همان بخشی است که سنیورها را از جونیورها جدا میکند. خیلیها میگویند «Lombok یک annotation processor است». این نیمهدرست و در نکتهی اصلی غلط است. بگذار دقیق شویم.
جاوا یک API رسمی به اسم Annotation Processing (همان javax.annotation.processing) دارد. با آن، یک پردازشگر میتواند اَنوتیشنها را ببیند و فایلهای جدید بسازد — مثلاً یک کلاسِ کاملاً تازه. اما این API عمداً یک محدودیت دارد: اجازه نمیدهد کلاسِ موجود را تغییر بدهی. یعنی نمیتوانی با API رسمی، متدِ getName را داخلِ همان کلاسِ User تزریق کنی. و این دقیقاً همان کاری است که Lombok میخواهد بکند.
پس Lombok چه میکند؟ خودش را بهعنوان یک annotation processor ثبت میکند تا سرِ کامپایل صدا زده شود، اما بعد یک ترفند میزند: بهجای ساختنِ فایلِ جدید، شیءهایی را که کامپایلر به آن میدهد به تایپهای داخلی و خصوصیِ javac (مثل com.sun.tools.javac.tree.JCTree) تبدیل (cast) میکند. این شیءها همان AST واقعیِ کامپایلر هستند، نه کپی. Lombok مستقیماً روی آن درخت گره اضافه میکند — متدِ getter را داخلِ گرهِ کلاس میکارد. کامپایلر بعد از این، انگار که آن متد را از اول خودت نوشته بودی، به کارش ادامه میدهد و بایتکد را میسازد.
تصور کن نویسندهای هستی و متنت را به چاپخانه دادی. یک annotation processor معمولی مثل کسی است که اجازه دارد فقط صفحهی جدید به آخرِ کتاب اضافه کند — نمیتواند صفحهی موجود را عوض کند. اما Lombok یواشکی به اتاقِ حروفچینی میرود، همان صفحهی اصلیِ در حالِ چاپ را برمیدارد و چند پاراگراف وسطِ آن اضافه میکند. کتابِ چاپشده کامل و بینقص است — اما این کار با درِ رسمی انجام نشده، از پنجرهی پشتی انجام شده. همین «پنجرهی پشتی» هم قدرتِ Lombok است و هم پاشنهی آشیلش.
چون com.sun.tools.javac.* یک API داخلی است، نه رسمی — یعنی Oracle هیچ تعهدی به پایدارماندنش ندارد و در هر نسخهی جدیدِ جاوا میتواند عوض شود. تاریخِ Lombok پر است از اینکه یک نسخهی JDK بیرون میآید و Lombok میشکند تا وصلهاش کنند: JDK 16 با محدودیتِ ماژولها (module jdk.compiler does not export ...) شکست؛ JDK 21 و 23 با تغییرِ فیلدهای داخلی خطای NoSuchFieldError دادند. درسِ عملی: هنگام ارتقای نسخهی جاوا، اول Lombok را به آخرین نسخه ببر، وگرنه ممکن است اصلاً کامپایل نشود.
برای Eclipse داستان کمی فرق دارد: آنجا Lombok خودش را بهعنوان یک Java agent به کامپایلرِ Eclipse (ecj) تزریق میکند و همان کارِ دستکاریِ AST را میکند. در هر دو حالت، نتیجهی نهایی یکی است: یک .class تمیز که هیچ وابستگیای به Lombok در زمانِ اجرا ندارد.
اَنوتیشنهای اصلی، یکییکی
@Getter و @Setter
سادهترینها. روی فیلد یا روی کلِ کلاس میآیند:
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class Account {
private Long id;
private String owner;
@Setter(AccessLevel.NONE) // فقط getter، setter نساز
private BigDecimal balance;
}
@Getter روی کلاس یعنی «برای همهی فیلدها getter بساز». میتوانی سطحِ دسترسی را کنترل کنی (AccessLevel.PROTECTED) یا با AccessLevel.NONE تولیدِ یکی را خاموش کنی. برای boolean isActive هم بهدرستی isActive() میسازد نه getIsActive().
@ToString و @EqualsAndHashCode
@ToString
@EqualsAndHashCode
public class Point {
private int x;
private int y;
}
@ToString یک نمایشِ خوانا مثل Point(x=3, y=4) میسازد. @EqualsAndHashCode هر دو متد را با هم و هماهنگ میسازد — و این «با هم» مهم است: قراردادِ جاوا میگوید اگر دو شیء equals باشند باید hashCode یکسان داشته باشند، و چون Lombok هر دو را از روی همان مجموعه فیلد میسازد، این قرارداد هیچوقت نمیشکند.
میتوانی فیلدها را کنترل کنی:
@ToString(exclude = "password")
@EqualsAndHashCode(of = {"id"})
public class User {
private Long id;
private String username;
private String password; // نه در toString بیاید، نه در equals
}
اگر کلاست از یک کلاسِ پدرِ دارای فیلد ارث میبرد، بهطور پیشفرض @EqualsAndHashCode فیلدهای پدر را نادیده میگیرد. برای درستشدن باید @EqualsAndHashCode(callSuper = true) بزنی تا equals/hashCodeِ پدر هم لحاظ شود. فراموشکردنِ این، یک منبعِ کلاسیکِ باگ در سلسلهمراتبهاست.
@Data — بستهی کامل
@Data میانبُرِ محبوب است: پنج اَنوتیشن را یکجا میآورد.
@Data شاملِ |
چه میسازد |
|---|---|
@Getter |
getter برای همهی فیلدها |
@Setter |
setter برای همهی فیلدهای غیر-final |
@ToString |
toString از روی همهی فیلدها |
@EqualsAndHashCode |
equals/hashCode از روی همهی فیلدها |
@RequiredArgsConstructor |
سازنده برای فیلدهای final و @NonNull |
یعنی @Data برای یک DTO یا شیءِ دادهی تغییرپذیر عالی است. اما همین «همهی فیلدها» بعداً در بخشِ JPA به دامِ اصلیِ ما تبدیل میشود؛ فعلاً نگهش دار.
سازندهها: سه اَنوتیشن
@NoArgsConstructor— سازندهی بدونِ آرگومان (برای فریمورکهایی که با reflection شیء میسازند، مثل JPA و Jackson، لازم است).@AllArgsConstructor— سازنده با تمامِ فیلدها.@RequiredArgsConstructor— سازنده فقط برای فیلدهای «الزامی»: هر فیلدِfinalکه مقداردهی نشده، و هر فیلدِ@NonNull.
پرکاربردترین کاربردِ @RequiredArgsConstructor تزریقِ وابستگی (dependency injection) در Spring است:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repository;
private final PaymentGateway payment;
// Lombok خودش سازندهی
// OrderService(OrderRepository, PaymentGateway) را میسازد
// و Spring از همان برای constructor injection استفاده میکند.
}
چون فیلدها final میمانند (تغییرناپذیر، thread-safe)، وابستگیهای الزامی صریحاند، و تستِ واحد بدونِ Spring هم راحت است (فقط سازنده را صدا میزنی و mock پاس میدهی). این ترکیب — @Service + @RequiredArgsConstructor + فیلدهای final — امروز شیوهی استانداردِ Spring است و @Autowired روی فیلد را کنار گذاشته.
@Value — نسخهی تغییرناپذیر
اگر @Data را «تغییرپذیر» بدان، @Value قلِ تغییرناپذیرِ آن است. یک @Value:
- کلاس را
finalمیکند، - همهی فیلدها را
private final، - getter میسازد ولی setter نه،
equals/hashCode/toStringو یک سازندهی همه-فیلده میسازد.
import lombok.Value;
@Value
public class Money {
Currency currency; // نیازی به نوشتن private final نیست؛ خودش میگذارد
BigDecimal amount;
}
این عملاً همان مفهومِ «شیءِ مقدار» (value object) است — و اینجا سوالِ بزرگ پیش میآید: پس فرقش با record جاوا چیست؟ به آن میرسیم.
@Builder — الگوی سازنده بدونِ درد
وقتی کلاسی چند فیلدِ اختیاری دارد، سازنده با ده آرگومان کابوس است (new Pizza(true, false, null, 12, ...) — کدام کدام است؟). الگوی Builder این را حل میکند و @Builder آن را رایگان میسازد:
import lombok.Builder;
@Builder
public class Pizza {
private String size;
private boolean cheese;
private boolean pepperoni;
private int slices;
}
// استفاده:
Pizza p = Pizza.builder()
.size("large")
.cheese(true)
.slices(8)
.build();
خوانا، امن و بیترتیب. اما یک دامِ بسیار مشهور دارد:
فرض کن مقدارِ پیشفرض بگذاری: private int slices = 8;. انتظار داری اگر .slices(...) را صدا نزنی، ۸ بگیری. اما نمیگیری — صفر میگیری! چرا؟ چون builderِ ساختهشده فیلدهای خودش را دارد و مقداردهیِ اولیهی تو در فیلدِ کلاس را نادیده میگیرد؛ فیلدِ نگذاشتهشده مقدارِ پیشفرضِ جاوا (صفر/null/false) میگیرد. راهحل: روی آن فیلد @Builder.Default بزن:
@Builder.Default
private int slices = 8; // حالا واقعاً پیشفرض ۸ میشود
پشتِ صحنه، Lombok یک پرچم نگه میدارد که آیا مقدار صریحاً set شده یا نه، و اگر نه، مقدارِ پیشفرضِ تو را میگذارد. این یکی از پرتکرارترین باگهای Lombok در پروداکشن است.
دو همراهِ مفیدِ @Builder:
@Singularروی فیلدهای مجموعهای:builder.topping("cheese").topping("basil")را ممکن میکند و مجموعهی نهایی را تغییرناپذیر میسازد.@Jacksonized(کنارِ@Builder) که builder را برای Jackson قابلِ deserialize میکند؛ در نسخههای اخیر هم Jackson 2 و هم Jackson 3 را پشتیبانی میکند.
@Slf4j و خانوادهی لاگ
نوشتنِ این خط در هر کلاس خودش boilerplate است:
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
@Slf4j همین را میسازد و یک فیلدِ log آماده در اختیارت میگذارد:
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class OrderService {
public void place(Order o) {
log.info("placing order {}", o.getId());
}
}
خواهرهایش: @Log4j2، @CommonsLog، @JBossLog و @Log (java.util.logging). در عمل @Slf4j استانداردِ صنعت است چون SLF4J یک facade است و پیادهسازی (Logback/Log4j2) را جدا نگه میدارد.
ابزارهای ظریفتر
val و var
val یعنی «متغیرِ محلیِ final که نوعش را خودت تشخیص بده»، و varِ Lombok همان بدونِ final:
val names = new ArrayList<String>(); // نوع: ArrayList<String> و final
نکتهی تاریخی مهم: var بومیِ جاوا از جاوا ۱۰ آمد (java.lang.var نیست، کلمهی کلیدیِ زبان است). پس امروز varِ Lombok تقریباً بیمصرف شده و valِ Lombok را میتوانی با final var بومی جایگزین کنی. این یک نمونهی خوب است از اینکه چطور خودِ زبان کمکم کارهای Lombok را میبلعد.
@NonNull
public void setName(@NonNull String name) {
this.name = name; // Lombok در ابتدای متد یک null-check تزریق میکند
}
اگر null بدهی، NullPointerException با پیامِ روشن پرتاب میشود — بهجای اینکه چند خط بعد در جای گیجکنندهای بترکد. روی فیلدهای سازنده هم کار میکند.
@SneakyThrows — دوستداشتنی و خطرناک
جاوا تو را مجبور میکند exceptionهای checked را یا بگیری یا در امضای متد اعلام کنی. گاهی این آزاردهنده است، مثلاً وقتی میدانی این خطا عملاً رخ نمیدهد:
import lombok.SneakyThrows;
@SneakyThrows
public String readConfig() {
return Files.readString(Path.of("config.txt")); // IOException است، ولی throws نمینویسیم
}
هیچچیز را wrap یا خفه نمیکند. exceptionِ checked را همانطور که هست پرتاب میکند، اما کامپایلر را گول میزند تا فکر کند این خطا unchecked است. این چطور ممکن است؟ چون در سطحِ بایتکد اصلاً چیزی به اسمِ «checked exception» وجود ندارد — تفکیکِ checked/unchecked فقط قانونِ زمانِ کامپایلِ خودِ جاواست. پس در زمانِ اجرا، پرتابِ هر exceptionی بدونِ اعلام کاملاً قانونی است. @SneakyThrows فقط این حقیقت را آشکار میکند.
وسوسهانگیز است اما بهراحتی سوءاستفاده میشود. مشکل: کدِ صداکننده نمیتواند آن خطا را با catch (IOException e) بگیرد، چون کامپایلر فکر میکند چنین خطایی ممکن نیست و اجازهی catch نمیدهد. پس اگر واقعاً میخواهی فراخواننده خطا را مدیریت کند، @SneakyThrows غلط است. جای درستش: کدِ چسبی مثل پیادهسازیِ Runnable که امضایش را نمیتوانی عوض کنی، یا خطاهایی که واقعاً «نشدنی»اند.
@Cleanup
مدیریتِ خودکارِ منابع بدونِ نوشتنِ try-with-resources:
@Cleanup InputStream in = new FileInputStream("data.bin");
// در پایانِ scope، Lombok خودش in.close() را صدا میزند
امروز که try-with-resources بومی هست، این کمتر لازم است، اما برای منابعی که AutoCloseable نیستند مفید میماند.
دامِ بزرگ: Lombok و JPA/Hibernate
اینجا مهمترین بخشِ عملیِ فصل است. @Data روی یک DTO بیخطر است، اما @Data روی یک entityِ JPA یک میدانِ مین است. سه فاجعه:
۱) فاجعهی equals/hashCode روی همهی فیلدها. entityها با کلیدِ اصلی (id) شناخته میشوند، اما این id اغلب null است تا لحظهی ذخیره در دیتابیس. حالا این سناریو را ببین:
@Entity
@Data // ← فاجعه
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private BigDecimal price;
}
Set<Product> set = new HashSet<>();
Product p = new Product(); // id هنوز null
set.add(p); // در سطلی بر اساسِ hashCodeِ فعلی مینشیند
repository.save(p); // حالا id مقدار میگیرد → hashCode عوض میشود!
set.contains(p); // false! چون در سطلِ اشتباه دنبالش میگردد
hashCode نباید در طولِ عمرِ شیء تغییر کند، اما @Data آن را وابسته به همهی فیلدها میکند و id در وسطِ کار عوض میشود. نتیجه: entity در HashSet/HashMap گم میشود.
۲) فاجعهی lazy loading و StackOverflow در toString. فرض کن رابطهی دوطرفه داری: Order یک لیست items دارد و هر OrderItem به order برمیگردد. @Data برای هر دو toString میسازد که فیلدها را چاپ میکند → Order.toString() روی items میرود، آن روی order برمیگردد، و... StackOverflowError. بدتر: صدازدنِ toString یا hashCode روی یک مجموعهی @OneToMany که lazy است، کلِ رابطه را از دیتابیس میکشد — یک کوئریِ سنگینِ ناخواسته، شاید بیرون از تراکنش (LazyInitializationException).
۳) مشکلِ proxy. Hibernate برای lazy loading از شیءِ entityِ تو یک proxy میسازد. equalsِ تولیدشده اگر getClass() را چک کند، proxy.getClass() != Product.class و مقایسه اشتباه از آب درمیآید.
راهِ استاندارد این است که equals/hashCode را فقط بر اساسِ کلیدِ اصلی بسازی، نه همهی فیلدها:
@Entity
@Getter @Setter
@ToString(onlyExplicitlyIncluded = true)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Product {
@Id @GeneratedValue
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
private String name; // نه در equals، نه در hashCode
private BigDecimal price;
@ToString.Exclude
@OneToMany(mappedBy = "product")
private List<Review> reviews; // از toString بیرون تا lazy-load نشود
}
onlyExplicitlyIncluded = true میگوید «هیچ فیلدی را خودت لحاظ نکن، فقط آنهایی که با .Include علامت زدم». نتیجه: equals/hashCode فقط به id تکیه میکنند، و رابطههای سنگین از toString بیروناند.
سریعترین راهِ درستکاری این است که قانون بگذاری: @Data و @Value هرگز روی entity. روی entityها فقط @Getter/@Setter بگذار و equals/hashCode را یا دستی و id-محور بنویس یا با onlyExplicitlyIncluded. خیلی تیمها این را در lombok.config یا در code-review اجبار میکنند. برای DTOها اما @Data/@Builder کاملاً درست و راحت است.
lombok.config — سیاستگذاریِ سراسری
میتوانی یک فایلِ lombok.config در ریشهی پروژه بگذاری تا رفتارِ Lombok را برای همهی فایلها یکجا تنظیم کنی. سیستمش «حبابی» (bubbling) است: تنظیماتِ پوشهی بالاتر روی زیرپوشهها اثر میگذارد مگر اینکه override شود.
# ریشهی درختِ تنظیمات همینجاست؛ بالاتر نرو
config.stopBubbling = true
# روی هر متد/فیلدِ ساختهشده، @lombok.Generated بزن
# (تا ابزارهای coverage مثل JaCoCo آنها را نادیده بگیرند)
lombok.addLombokGeneratedAnnotation = true
# اگر callSuper را صریح نگذاشتی، هشدار بده
lombok.equalsAndHashCode.callSuper = warn
# استفاده از @Data را در کل پروژه ممنوع کن (اجبارِ سیاست)
lombok.data.flagUsage = error
lombok.addLombokGeneratedAnnotation = true را تقریباً همیشه روشن کن؛ بدونِ آن، متدهای ساختهشده در گزارشِ coverage بهعنوانِ «تستنشده» ظاهر میشوند و درصدت را الکی خراب میکنند. و lombok.<feature>.flagUsage = error ابزارِ فوقالعادهای است برای اینکه استفادههای خطرناک (مثل @Data) را در کلِ کدبیس ممنوع کنی.
delombok — دیدنِ کدِ واقعی و راهِ خروج
delombok کدِ منبعِ تو را میگیرد و نسخهای میسازد که همهی اَنوتیشنهای Lombok در آن باز شدهاند — یعنی @Getter تبدیل به متدِ getterِ واقعی میشود. دو کاربردِ اصلی:
- دیدن اینکه Lombok دقیقاً چه ساخته — عالی برای یادگیری و دیباگ.
- راهِ خروج (exit strategy) — اگر روزی خواستی Lombok را از پروژه حذف کنی، delombok کدِ معادلِ بدونِ Lombok را برایت تولید میکند و میتوانی همان را commit کنی. این پاسخِ خوبی است به نگرانیِ «اگر گیرِ Lombok بیفتیم چه؟».
java -jar lombok.jar delombok src/main/java -d target/delomboked
نقدها: کِی سراغِ Lombok نرو
Lombok محبوب است اما بیمنتقد نیست. یک سنیور باید هر دو طرف را بشناسد:
- اتکا به APIهای داخلی. همان پنجرهی پشتیِ
com.sun.tools.javacکه بارها با ارتقای JDK شکسته. این یک ریسکِ ساختاری است. - جادوی نامرئی. کدی که نمیبینی، فهمش برای تازهواردِ تیم سختتر است. «
getNameاز کجا آمد؟» - وابستگی به ابزار. بدونِ پلاگینِ IDE، تجربه خراب است؛ ابزارهای تحلیلِ استاتیک و پردازشگرهای دیگرِ اَنوتیشن گاهی با Lombok دعوا دارند (ترتیبِ اجرای پردازشگرها).
- دیباگِ سختتر. step روی کدِ ساختهشده مبهم است.
- سوءاستفادهی آسان.
@Dataهمهجا،@SneakyThrowsبیجا، setterهایی که تغییرناپذیری را نابود میکنند.
هیچکدامِ اینها Lombok را «بد» نمیکنند؛ فقط میگویند با آگاهی استفاده کن. میلیونها پروژه با موفقیت از Lombok استفاده میکنند. کلید این است: برای DTO/builder/logger عالی است؛ روی entity و در جاهایی که تغییرناپذیری یا کنترلِ دقیق مهم است محتاط باش؛ و همیشه یک راهِ خروج (delombok) در ذهن داشته باش.
record در برابر Lombok
جاوا ۱۶ record را نهایی کرد و بلافاصله سوال پیش آمد: «پس دیگر Lombok لازم نیست؟». پاسخِ دقیق: در بخشی از قلمروِ Lombok، بله؛ در بخشِ دیگر، نه.
// record بومیِ جاوا — بدونِ هیچ کتابخانهای
public record Money(Currency currency, BigDecimal amount) {}
این record خودش private final fields، سازنده، accessorها (به شکلِ amount() نه getAmount())، و equals/hashCode/toString میسازد — دقیقاً قلمروِ @Value.
| ویژگی | record (بومی) |
Lombok |
|---|---|---|
| نیاز به کتابخانه | ندارد (بومیِ زبان) | دارد (وابستگی + پلاگین) |
| تغییرناپذیری | اجباری و ذاتی | اختیاری (@Value یا با final) |
| نامِ accessor | amount() |
getAmount() |
| فیلدِ تغییرپذیر / setter | ممکن نیست | با @Data/@Setter ممکن است |
| ارثبری از کلاسِ دیگر | ممکن نیست (فقط interface) | ممکن است |
| Builder | بومی ندارد (باید دستی) | @Builder |
| پایداری در برابرِ ارتقای JDK | کاملاً پایدار | وابسته به APIهای داخلی |
اگر چیزی که میسازی یک بستهی دادهی تغییرناپذیر و ساده است، امروز record انتخابِ اول است: بومی، پایدار، بدونِ وابستگی. اما اگر به فیلدِ تغییرپذیر و setter (مثل خیلی DTOها یا entityها)، به @Builder روی کلاسِ معمولی، به ارثبری، یا به @Slf4j/@RequiredArgsConstructor نیاز داری، Lombok هنوز کارِ خودش را دارد. این دو رقیبِ تمامعیار نیستند؛ همپوشانیشان فقط بخشِ «value object» است.
نکتهی مهم: record و Lombok میتوانند کنارِ هم باشند. مثلاً @Builder روی یک record کار میکند، و @Slf4j هم. پس لازم نیست یکی را کاملاً کنار بگذاری.
چند بهترینشیوه که در ذهن نگه دار
- روی DTO ها راحت
@Data/@Builderبزن. - روی entity هرگز
@Data/@Value؛ فقط@Getter/@Setter+ equals/hashCode باonlyExplicitlyIncludedروی id. - برای سرویسهای Spring،
@RequiredArgsConstructor+ فیلدهایfinal. - برای value objectِ ساده، اول
recordرا در نظر بگیر، بعد@Value. lombok.addLombokGeneratedAnnotation = trueرا روشن کن.@Builder.Defaultرا برای هر فیلدِ دارای مقدارِ پیشفرض یادت نرود.@SneakyThrowsرا فقط جایی که واقعاً معنی دارد بهکار ببر.- هنگامِ ارتقای JDK، اول Lombok را بهروز کن.
پرسشهای مصاحبه
نه، و همین تفاوت پاسخِ کلیدی است. API رسمیِ Annotation Processing جاوا فقط اجازهی ساختنِ فایلِ جدید را میدهد، نه تغییرِ کلاسِ موجود. Lombok خودش را بهعنوان annotation processor ثبت میکند تا سرِ کامپایل صدا زده شود، اما بعد شیءهای کامپایلر را به تایپهای داخلیِ com.sun.tools.javac (مثل JCTree) تبدیل میکند و مستقیماً روی AST واقعیِ کامپایلر گره اضافه میکند — یعنی متدها را داخلِ همان کلاس میکارد. کامپایلر بعد از آن انگار خودت نوشته باشیشان بایتکد میسازد. برای Eclipse از یک Java agent استفاده میکند. پیامدِ مهم: خروجی یک .class تمیز و بدونِ وابستگیِ زمانِ اجراست، اما چون به APIهای داخلی تکیه دارد، با هر نسخهی جدیدِ جاوا میتواند بشکند.
چون Lombok فقط در زمانِ کامپایل کار میکند و در زمانِ اجرا هیچ نقشی ندارد؛ .classهای تولیدشده هیچ ارجاعی به Lombok ندارند. پس نباید Lombok را داخلِ jarِ نهایی و کلاسپثِ اجرا بفرستی. provided (Maven) و compileOnly (Gradle) دقیقاً همین را میگویند. در Gradle باید علاوهبر compileOnly آن را بهعنوان annotationProcessor هم اعلام کنی تا واقعاً سرِ کامپایل فعال شود.
@Data = @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor. خطرِ روی entity از سه جا میآید: (۱) equals/hashCode بر اساسِ همهی فیلدها ساخته میشود، اما id در JPA اغلب null است تا لحظهی save و بعد مقدار میگیرد — پس hashCode در طولِ عمرِ شیء تغییر میکند و entity در HashSet/HashMap گم میشود. (۲) toString/hashCode روی رابطههای lazy میتوانند کوئریِ ناخواسته بزنند یا LazyInitializationException بدهند، و در رابطهی دوطرفه toString به StackOverflowError میرسد. (۳) proxyهای Hibernate چکِ getClass() را میشکنند. راهحل: @EqualsAndHashCode(onlyExplicitlyIncluded = true) با @EqualsAndHashCode.Include فقط روی id، یا اصلاً @Data روی entity نزدن.
اگر روی یک فیلد مقدارِ پیشفرض بگذاری (private int x = 8;) و از @Builder استفاده کنی، وقتی آن فیلد را در builder set نکنی مقدارِ پیشفرضِ جاوا (صفر) میگیری، نه ۸. علتش این است که builder فیلدهای مستقلِ خودش را دارد و مقداردهیِ اولیهی فیلدِ کلاس را نمیبیند. برای درستشدن باید @Builder.Default را روی آن فیلد بگذاری؛ آنوقت Lombok یک پرچمِ «آیا set شد؟» نگه میدارد و اگر set نشده بود مقدارِ پیشفرضِ تو را میگذارد. این یکی از رایجترین باگهای خاموشِ Lombok است.
در سطحِ بایتکد اصلاً تفکیکِ checked/unchecked وجود ندارد؛ این تمایز فقط یک قانونِ زمانِ کامپایلِ جاواست. @SneakyThrows هیچچیز را wrap یا خفه نمیکند؛ فقط کامپایلر را گول میزند تا فکر کند خطا unchecked است، و چون JVM اجازهی پرتابِ هر exceptionی را میدهد، در زمانِ اجرا کاملاً کار میکند. عیبش: کدِ فراخواننده نمیتواند آن خطا را با catch بگیرد چون کامپایلر فکر میکند چنین خطایی ممکن نیست. پس فقط جایی بهکارش ببر که یا خطا واقعاً «نشدنی» است یا امضای متد را نمیتوانی عوض کنی (مثل Runnable.run).
هر دو یک بستهی دادهی تغییرناپذیر میسازند (private final fields، سازنده، equals/hashCode/toString). تفاوتها: record بومیِ زبان است (بدونِ وابستگی، بدونِ پلاگین، پایدار در برابرِ ارتقای JDK)، accessorهایش amount() هستند نه getAmount()، تغییرناپذیریاش اجباری است و نمیتواند از کلاسِ دیگری ارث ببرد. @Value وابسته به Lombok است اما انعطافِ بیشتری (نامِ getter به سبکِ JavaBean، سازگاری با ابزارهای قدیمی) میدهد. قاعده: برای value objectِ ساده امروز record انتخابِ اول است؛ سراغِ @Value وقتی میروی که به سبکِ getterِ JavaBean یا سازگاری با فریمورکی که record نمیفهمد نیاز داری.
چون Lombok برای دستکاریِ AST به APIهای داخلیِ کامپایلر (com.sun.tools.javac.*) تکیه میکند که Oracle هیچ تعهدی به پایدارماندنشان ندارد. نمونهها: JDK 16 با سیستمِ ماژولها دسترسی به این پکیجها را بست (does not export)، و JDK 21/23 با تغییرِ فیلدهای داخلی خطای NoSuchFieldError دادند. به همین دلیل قاعده این است که هنگامِ ارتقای JDK اول Lombok را به آخرین نسخه (مثلاً 1.18.46 که JDK 24/25 را پشتیبانی میکند) ببری.
delombok کدِ منبعِ دارای اَنوتیشنهای Lombok را میگیرد و نسخهای تولید میکند که همهی آن اَنوتیشنها به کدِ جاوای معمولیِ معادل باز شدهاند — مثلاً @Getter به متدِ getterِ واقعی. دو کاربرد: (۱) دیدنِ اینکه Lombok دقیقاً چه ساخته، برای یادگیری و دیباگ؛ (۲) استراتژیِ خروج — اگر روزی خواستی Lombok را حذف کنی، delombok کدِ معادلِ بدونِ Lombok را میسازد و همان را commit میکنی. این پاسخِ عملی به نگرانیِ «اگر به Lombok قفل شویم چه؟» است.
چون سازندهای فقط برای فیلدهای final (و @NonNull) میسازد، و Spring از constructor injection استفاده میکند. مزایا: فیلدها final و در نتیجه تغییرناپذیر و thread-safe میمانند؛ وابستگیهای الزامی صریحاند و شیء همیشه در حالتِ کامل ساخته میشود؛ و تستِ واحد بدونِ راهاندازیِ کلِ Spring ممکن است چون فقط سازنده را با mock صدا میزنی. این جایگزینِ مدرنِ @Autowired روی فیلد است که تستِ سختتر و فیلدهای تغییرپذیر داشت.
callSuper = true باعث میشود equals/hashCodeِ ساختهشده، نتیجهی equals/hashCodeِ کلاسِ پدر را هم لحاظ کنند؛ در سلسلهمراتبهای دارای فیلد در پدر، فراموشکردنش یک باگِ کلاسیک است. onlyExplicitlyIncluded = true رفتار را از «همهی فیلدها را لحاظ کن مگر آنها که exclude کردم» به «هیچ فیلدی را لحاظ نکن مگر آنها که با @EqualsAndHashCode.Include علامت زدم» تغییر میدهد — دقیقاً همان چیزی که برای entityهای JPA میخواهیم تا فقط id لحاظ شود.
چون این متدها همهی فیلدها را میخوانند، و اگر فیلدی یک رابطهی @OneToMany/@ManyToMany با FetchType.LAZY باشد، خواندنش proxy را وادار به بارگذاری از دیتابیس میکند — یک کوئریِ سنگینِ ناخواسته. اگر این خارج از یک تراکنش یا session باز اتفاق بیفتد، به LazyInitializationException میرسی. راهحل: آن فیلدها را با @ToString.Exclude و با استفاده از onlyExplicitlyIncluded از equals/hashCode/toString بیرون بگذار.
(۱) lombok.addLombokGeneratedAnnotation = true روی هر عضوِ ساختهشده اَنوتیشنِ @lombok.Generated میزند تا ابزارهای coverage مثل JaCoCo آن کدِ خودکار را نادیده بگیرند؛ بدونِ آن، درصدِ coverage الکی پایین میآید. (۲) config.stopBubbling = true میگوید ریشهی درختِ تنظیمات همینجاست تا Lombok بالاتر از پروژه دنبالِ فایلِ config نگردد. یک تنظیمِ مفیدِ دیگر lombok.<feature>.flagUsage = error است که استفادهی خطرناک مثل @Data را در کلِ کدبیس ممنوع میکند.
فقط بخشی از آن را. record دقیقاً قلمروِ «value objectِ تغییرناپذیر» را میپوشاند و آنجا انتخابِ اول است چون بومی و پایدار است. اما Lombok چیزهایی دارد که record ندارد: فیلدِ تغییرپذیر و setter (برای DTOها و entityها)، @Builder روی کلاسِ معمولی، @Slf4j، @RequiredArgsConstructor، ارثبری از کلاس. حتی میشود @Builder و @Slf4j را روی خودِ record هم گذاشت. پس این دو رقیبِ کامل نیستند؛ فقط در بخشِ value object همپوشانی دارند.
چون var از جاوا ۱۰ بهصورتِ بومیِ زبان آمد و valِ Lombok را هم میشود با final var بومی جایگزین کرد؛ پس زبان این قابلیتِ Lombok را بلعید. اما جاوا هنوز هیچ الگوی builderِ داخلی ندارد، و ساختنِ دستیِ builder همان boilerplateِ سنگین است. تا وقتی زبان چیزی معادلِ @Builder نداشته باشد، این اَنوتیشن ارزشش را حفظ میکند. این یک الگوی کلی است: هرچه زبان جلو میرود، بخشی از قلمروِ Lombok را پس میگیرد، اما نه همهاش را.
Lombok با چند اَنوتیشن (@Getter/@Setter، @Data، @Value، @Builder، @Slf4j، @RequiredArgsConstructor) کدِ تکراری را حذف میکند و در نتیجه یک کلاسِ کاملِ باگهای ناشی از دستنویسیِ getter/equals/hashCode را میخشکاند. جادویش این است که یک annotation processor معمولی نیست: به APIهای داخلیِ javac دست میبرد و AST را دستکاری میکند — قدرتش و شکنندگیاش هر دو از همینجاست، برای همین با ارتقای JDK باید بهروز بماند. طلاییِ کاربردش DTO، builder، logger و تزریقِ وابستگی است. دامِ اصلیاش @Data روی entityِ JPA است (equals/hashCode روی همهی فیلدها، lazy-load، StackOverflow) که با @EqualsAndHashCode(onlyExplicitlyIncluded=true) روی id درمان میشود؛ و دامِ خاموشش @Builder.Default. برای value objectهای ساده، record بومی امروز انتخابِ اول است، اما Lombok در قلمروِ تغییرپذیری، builder و logger هنوز جای خودش را دارد. با آگاهی بهکارش ببر، lombok.config را تنظیم کن، و همیشه delombok را بهعنوانِ راهِ خروج در ذهن داشته باش. کدِ کمتر، اما مهمتر: باگِ کمتر.
Let's start with a truth anyone who has written Java for a few months has felt in their bones: Java is a verbose language. Just to say "a user has a name and an email," you write the fields, then a getter and setter for each, then equals, hashCode, toString, and a constructor or two. A class whose idea is two lines becomes fifty on screen — and those fifty lines aren't just tedious, they're dangerous: forget one field in hashCode and you'll spend nights hunting a silent bug.
Lombok was built to cure exactly this pain. In this chapter you won't just memorize a list of annotations — you'll understand what code each one generates, how that generation actually happens inside the compiler, where the magic turns into a trap, and how to talk about it in a senior interview so it's clear you're not just a user but someone who knows what's happening under the hood.
Here's the path we'll walk together:
- Feel the pain — what "boilerplate" is and why it's not just laziness but a source of bugs.
- Wire up Lombok — the Maven/Gradle dependency, what scope means, and why you also need an IDE plugin.
- The magic under the hood — Lombok is not a normal annotation processor; it rewrites the compiler's AST. This distinction is everything.
- The core annotations —
@Getter/@Setter,@ToString,@EqualsAndHashCode,@Data, constructors,@Value,@Builder,@Slf4j. - The finer tools —
val/var,@NonNull,@SneakyThrows,@Cleanup. - The traps — especially the
@Data-on-a-JPA-entity disaster and how to fix it properly. lombok.config, delombok, criticisms, and records vs Lombok.- Interview questions with full answers, and a wrap-up.
Part 0 — a few words you must feel before we start
Before any code, three terms recur throughout this chapter. Let me plant them now.
- Boilerplate: code you have to write but that contains no new logic — getters, setters, constructors. The name comes from pre-printed newspaper plates where only the name was swapped. Boilerplate is bad not because it's long, but because it's the wrong place: every hand-written line is a chance for a bug.
- Annotation: a metadata label on your code, like
@Override. It has no behavior itself; it's just a note on a class/field/method that other tools can read and act upon. - Bytecode and the AST: the compiler (
javac) first turns your code into an in-memory structural tree called the AST (Abstract Syntax Tree), then generates bytecode (.class) from that tree, which the JVM runs. Hold onto this sentence; Lombok's entire secret lives in that "tree."
Imagine that for every task at an office you must fill out a ten-page form where nine pages are always identical and only one line differs. Your real work is that single line; the rest is repetitive toil and a place to err (tick one wrong box and the whole form is rejected). Lombok is the clerk who fills out those nine unchanging pages, flawlessly, and leaves you just the one line that matters.
Why Lombok at all? See the pain in code
Let's hand-write a perfectly ordinary data class — a user with three fields, written "correctly":
public class User {
private final Long id;
private String name;
private String email;
public User(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id)
&& Objects.equals(name, user.name)
&& Objects.equals(email, user.email);
}
@Override
public int hashCode() {
return Objects.hash(id, name, email);
}
@Override
public String toString() {
return "User{id=" + id + ", name='" + name + "', email='" + email + "'}";
}
}
Nearly forty lines, and the idea was simply "a user with three fields." Now the same with Lombok:
import lombok.Data;
@Data
public class User {
private final Long id;
private String name;
private String email;
}
Five lines. The point isn't that it's shorter; the point is that the hand-written version was error-prone and this one isn't. If tomorrow you add a phone field, the hand-written version requires you to remember to add it to equals, hashCode, toString, and the constructor — and it's exactly that "remember" that breeds bugs. Lombok keeps them all in sync automatically.
Lombok reduces code length, but its real value is reducing bugs: code you don't write, you can't break, and it never drifts out of sync with your fields. The chapter title isn't accidental — "less boilerplate, fewer bugs."
Wire up Lombok
Lombok isn't an ordinary library you call at runtime; it works only at compile time. That's why its scope matters.
In Maven:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
<scope>provided</scope>
</dependency>
In modern Gradle, the correct form declares it both as an annotationProcessor and compileOnly:
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.46'
annotationProcessor 'org.projectlombok:lombok:1.18.46'
}
Because Lombok does nothing at runtime. Its job is to generate code during compilation; after that, the output is a perfectly ordinary .class with no trace of Lombok in it. So you must not ship Lombok inside your final jar and runtime classpath. provided (Maven) and compileOnly (Gradle) say exactly that: "be there at compile time, not at run time." The stable version as of this writing is 1.18.46 (April 2026), which also supports JDK 24 and 25.
A gotcha that drives beginners mad: the command-line compiler (mvn/gradle) builds the code fine, but the IDE draws red squiggles saying "getName does not exist." Why? Because the IDE can't see Lombok's generated code unless it has the plugin. In recent IntelliJ IDEA the Lombok plugin ships bundled and you just enable annotation processing; in Eclipse you run the Lombok jar once against your Eclipse install so it "injects" itself. Without this, the development experience is torture.
The magic under the hood: how Lombok really works
This is the part that separates seniors from juniors. Many people say "Lombok is an annotation processor." That's half true and wrong on the key point. Let's be precise.
Java has an official API called Annotation Processing (javax.annotation.processing). With it, a processor can inspect annotations and generate new files — say, a brand-new class. But this API has a deliberate limitation: it does not let you modify an existing class. You cannot use the official API to inject the getName method inside the existing User class. And that's exactly what Lombok wants to do.
So what does Lombok do? It registers itself as an annotation processor so it gets invoked during compilation, then pulls a trick: instead of generating a new file, it casts the objects the compiler hands it to internal, private javac types (like com.sun.tools.javac.tree.JCTree). Those objects are the compiler's actual AST, not copies. Lombok mutates that tree directly — it plants the getter method inside the class node. From there the compiler carries on and produces bytecode as if you'd written that method yourself.
Imagine you're an author who handed a manuscript to the print shop. A normal annotation processor is like someone allowed only to add new pages to the end of the book — they can't alter existing pages. But Lombok quietly slips into the typesetting room, grabs the page already on the press, and inserts a few paragraphs into the middle of it. The printed book is complete and flawless — but the job wasn't done through the front door; it was done through the back window. That back window is both Lombok's power and its Achilles' heel.
Because com.sun.tools.javac.* is an internal API, not a public one — Oracle makes no promise to keep it stable, and it can change in any new Java release. Lombok's history is full of a new JDK dropping and Lombok breaking until it's patched: JDK 16 broke it with module restrictions (module jdk.compiler does not export ...); JDK 21 and 23 caused NoSuchFieldError by changing internal fields. The practical lesson: when upgrading your Java version, bump Lombok to its latest release first, or it may not compile at all.
For Eclipse the story differs slightly: there Lombok injects itself as a Java agent into the Eclipse compiler (ecj) and performs the same AST manipulation. In both cases the end result is identical: a clean .class with zero runtime dependency on Lombok.
The core annotations, one by one
@Getter and @Setter
The simplest ones. They go on a field or on the whole class:
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class Account {
private Long id;
private String owner;
@Setter(AccessLevel.NONE) // getter only, no setter
private BigDecimal balance;
}
@Getter on the class means "generate a getter for every field." You can control the access level (AccessLevel.PROTECTED) or turn off one with AccessLevel.NONE. For a boolean isActive it correctly generates isActive(), not getIsActive().
@ToString and @EqualsAndHashCode
@ToString
@EqualsAndHashCode
public class Point {
private int x;
private int y;
}
@ToString produces a readable form like Point(x=3, y=4). @EqualsAndHashCode generates both methods together and in sync — and that "together" matters: Java's contract says equal objects must have equal hash codes, and because Lombok derives both from the same set of fields, that contract never breaks.
You can control the fields:
@ToString(exclude = "password")
@EqualsAndHashCode(of = {"id"})
public class User {
private Long id;
private String username;
private String password; // in neither toString nor equals
}
If your class inherits fields from a parent, @EqualsAndHashCode ignores the parent's fields by default. To fix that, use @EqualsAndHashCode(callSuper = true) so the parent's equals/hashCode are factored in. Forgetting this is a classic bug in class hierarchies.
@Data — the full bundle
@Data is the popular shortcut: it bundles five annotations at once.
@Data includes |
what it generates |
|---|---|
@Getter |
a getter for every field |
@Setter |
a setter for every non-final field |
@ToString |
toString over all fields |
@EqualsAndHashCode |
equals/hashCode over all fields |
@RequiredArgsConstructor |
a constructor for final and @NonNull fields |
So @Data is great for a mutable DTO or data object. But that very "all fields" becomes our main trap later in the JPA section; hold onto it.
Constructors: three annotations
@NoArgsConstructor— a no-argument constructor (required by frameworks that build objects via reflection, like JPA and Jackson).@AllArgsConstructor— a constructor with all fields.@RequiredArgsConstructor— a constructor only for "required" fields: every uninitializedfinalfield, and every@NonNullfield.
The most common use of @RequiredArgsConstructor is dependency injection in Spring:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repository;
private final PaymentGateway payment;
// Lombok generates
// OrderService(OrderRepository, PaymentGateway)
// and Spring uses it for constructor injection.
}
Because the fields stay final (immutable, thread-safe), required dependencies are explicit, and unit testing without Spring is trivial (just call the constructor and pass mocks). This combination — @Service + @RequiredArgsConstructor + final fields — is today's Spring standard and has retired field-level @Autowired.
@Value — the immutable sibling
If @Data is "mutable," @Value is its immutable twin. A @Value:
- makes the class
final, - makes every field
private final, - generates getters but no setters,
- generates
equals/hashCode/toStringand an all-args constructor.
import lombok.Value;
@Value
public class Money {
Currency currency; // no need to write private final; it's added for you
BigDecimal amount;
}
This is effectively the "value object" concept — which raises the big question: how does it differ from a Java record? We'll get there.
@Builder — the builder pattern without the pain
When a class has several optional fields, a constructor with ten arguments is a nightmare (new Pizza(true, false, null, 12, ...) — which is which?). The Builder pattern solves this, and @Builder gives it to you for free:
import lombok.Builder;
@Builder
public class Pizza {
private String size;
private boolean cheese;
private boolean pepperoni;
private int slices;
}
// usage:
Pizza p = Pizza.builder()
.size("large")
.cheese(true)
.slices(8)
.build();
Readable, safe, order-free. But it has a very famous trap:
Say you set a default value: private int slices = 8;. You'd expect that if you don't call .slices(...), you get 8. But you don't — you get zero! Why? Because the generated builder has its own fields and ignores your class-field initializer; an unset field takes Java's default (0/null/false). The fix: put @Builder.Default on that field:
@Builder.Default
private int slices = 8; // now the default really is 8
Behind the scenes, Lombok keeps a flag tracking whether the value was set explicitly, and if not, applies your default. This is one of the most frequent Lombok bugs in production.
Two useful companions of @Builder:
@Singularon collection fields: enablesbuilder.topping("cheese").topping("basil")and makes the final collection immutable.@Jacksonized(alongside@Builder) makes the builder deserializable by Jackson; recent versions support both Jackson 2 and Jackson 3.
@Slf4j and the logging family
Writing this line in every class is itself boilerplate:
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
@Slf4j generates exactly that and hands you a ready log field:
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class OrderService {
public void place(Order o) {
log.info("placing order {}", o.getId());
}
}
Its siblings: @Log4j2, @CommonsLog, @JBossLog, and @Log (java.util.logging). In practice @Slf4j is the industry standard because SLF4J is a facade that keeps the implementation (Logback/Log4j2) decoupled.
The finer tools
val and var
val means "a final local variable whose type you infer," and Lombok's var is the same without final:
val names = new ArrayList<String>(); // type: ArrayList<String>, and final
An important historical note: native var arrived in Java 10 (it's not java.lang.var — it's a reserved type name of the language). So today Lombok's var is nearly useless, and Lombok's val can be replaced by native final var. This is a good example of how the language gradually absorbs Lombok's features.
@NonNull
public void setName(@NonNull String name) {
this.name = name; // Lombok injects a null-check at the start of the method
}
If you pass null, a NullPointerException with a clear message is thrown — instead of blowing up a few lines later in some confusing place. It also works on constructor parameters.
@SneakyThrows — lovely and dangerous
Java forces you to either catch checked exceptions or declare them in the method signature. Sometimes that's annoying, e.g. when you know the error effectively can't happen:
import lombok.SneakyThrows;
@SneakyThrows
public String readConfig() {
return Files.readString(Path.of("config.txt")); // throws IOException, but we don't declare it
}
Nothing is wrapped or swallowed. It throws the checked exception exactly as-is, but fools the compiler into thinking it's unchecked. How is that possible? Because at the bytecode level there is no such thing as a "checked exception" — the checked/unchecked split is purely a compile-time rule of Java itself. So at runtime, throwing any exception without declaring it is perfectly legal. @SneakyThrows merely exposes that fact.
It's tempting but easily abused. The problem: calling code can't catch that error with catch (IOException e), because the compiler thinks such an error is impossible and won't allow the catch. So if you actually want the caller to handle the error, @SneakyThrows is wrong. Its right home: glue code like a Runnable implementation whose signature you can't change, or errors that are genuinely "can't happen."
@Cleanup
Automatic resource management without writing try-with-resources:
@Cleanup InputStream in = new FileInputStream("data.bin");
// at the end of scope, Lombok calls in.close() for you
Now that try-with-resources is native, this is less needed, but it stays useful for resources that aren't AutoCloseable.
The big trap: Lombok and JPA/Hibernate
This is the most practical part of the chapter. @Data on a DTO is harmless, but @Data on a JPA entity is a minefield. Three disasters:
1) The all-fields equals/hashCode disaster. Entities are identified by their primary key (id), but that id is often null until the moment it's saved to the database. Watch this scenario:
@Entity
@Data // ← disaster
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private BigDecimal price;
}
Set<Product> set = new HashSet<>();
Product p = new Product(); // id is still null
set.add(p); // lands in a bucket based on the current hashCode
repository.save(p); // now id gets a value → hashCode changes!
set.contains(p); // false! it looks in the wrong bucket
hashCode must not change during an object's lifetime, but @Data makes it depend on all fields, and the id changes mid-flight. Result: the entity gets lost in a HashSet/HashMap.
2) The lazy-loading and StackOverflow disaster in toString. Suppose you have a bidirectional relationship: an Order has a list of items, and each OrderItem refers back to its order. @Data generates toString for both, printing the fields → Order.toString() walks into items, which walks back into order, and... StackOverflowError. Worse: calling toString or hashCode on a lazy @OneToMany collection fetches the entire relationship from the database — an unwanted heavy query, possibly outside a transaction (LazyInitializationException).
3) The proxy problem. Hibernate builds a proxy of your entity object for lazy loading. If the generated equals checks getClass(), then proxy.getClass() != Product.class and the comparison comes out wrong.
The standard approach is to build equals/hashCode from the primary key only, not from all fields:
@Entity
@Getter @Setter
@ToString(onlyExplicitlyIncluded = true)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Product {
@Id @GeneratedValue
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
private String name; // in neither equals nor hashCode
private BigDecimal price;
@ToString.Exclude
@OneToMany(mappedBy = "product")
private List<Review> reviews; // kept out of toString so it isn't lazy-loaded
}
onlyExplicitlyIncluded = true says "don't include any field on your own, only the ones I marked with .Include." Result: equals/hashCode rely solely on the id, and heavy relationships stay out of toString.
The fastest way to be correct is to set a rule: @Data and @Value never on an entity. On entities use only @Getter/@Setter, and write equals/hashCode id-based either by hand or with onlyExplicitlyIncluded. Many teams enforce this via lombok.config or code review. For DTOs, though, @Data/@Builder are perfectly fine and convenient.
lombok.config — project-wide policy
You can drop a lombok.config file at the project root to tune Lombok's behavior for all files at once. The system is "bubbling": a parent directory's settings apply to child directories unless overridden.
# the config tree root is here; don't look higher
config.stopBubbling = true
# put @lombok.Generated on every generated member
# (so coverage tools like JaCoCo ignore them)
lombok.addLombokGeneratedAnnotation = true
# warn if callSuper wasn't set explicitly
lombok.equalsAndHashCode.callSuper = warn
# ban @Data across the whole project (policy enforcement)
lombok.data.flagUsage = error
Turn on lombok.addLombokGeneratedAnnotation = true almost always; without it, generated methods show up as "untested" in coverage reports and needlessly tank your percentage. And lombok.<feature>.flagUsage = error is a superb tool for banning dangerous usages (like @Data) across the entire codebase.
delombok — see the real code, and the way out
delombok takes your source code and produces a version where all Lombok annotations are expanded — that is, @Getter becomes the actual getter method. Two main uses:
- See exactly what Lombok generated — great for learning and debugging.
- Exit strategy — if one day you want to remove Lombok from a project, delombok generates the equivalent Lombok-free code, and you can commit that. It's a solid answer to the worry "what if we get locked into Lombok?"
java -jar lombok.jar delombok src/main/java -d target/delomboked
Criticisms: when not to reach for Lombok
Lombok is popular but not beyond criticism. A senior should know both sides:
- Reliance on internal APIs. That
com.sun.tools.javacback window has broken repeatedly across JDK upgrades. This is a structural risk. - Invisible magic. Code you don't see is harder for a new teammate to understand. "Where did
getNamecome from?" - Tooling dependence. Without the IDE plugin the experience is broken; static-analysis tools and other annotation processors sometimes clash with Lombok (processor ordering).
- Harder debugging. Stepping through generated code is murky.
- Easy to misuse.
@Dataeverywhere,@SneakyThrowswhere it doesn't belong, setters that destroy immutability.
None of this makes Lombok "bad"; it just means use it with awareness. Millions of projects use Lombok successfully. The key: it's great for DTOs/builders/loggers; be cautious on entities and wherever immutability or fine control matters; and always keep an exit route (delombok) in mind.
Records vs Lombok
Java 16 finalized records, and the question came up immediately: "so we don't need Lombok anymore?" The precise answer: in part of Lombok's territory, yes; in the rest, no.
// a native Java record — no library at all
public record Money(Currency currency, BigDecimal amount) {}
This record itself generates private final fields, a constructor, accessors (as amount(), not getAmount()), and equals/hashCode/toString — exactly @Value's territory.
| Feature | record (native) |
Lombok |
|---|---|---|
| Needs a library | No (language-native) | Yes (dependency + plugin) |
| Immutability | Mandatory and intrinsic | Optional (@Value or via final) |
| Accessor name | amount() |
getAmount() |
| Mutable field / setter | Not possible | Possible via @Data/@Setter |
| Extending another class | Not possible (interfaces only) | Possible |
| Builder | None built-in (hand-write it) | @Builder |
| Stability across JDK upgrades | Fully stable | Depends on internal APIs |
If what you're modeling is a simple immutable data bundle, a record is today's first choice: native, stable, dependency-free. But if you need a mutable field and setter (like many DTOs or entities), a @Builder on an ordinary class, inheritance, or @Slf4j/@RequiredArgsConstructor, Lombok still has a job. They're not full rivals; they overlap only in the "value object" region.
An important note: records and Lombok can coexist. For instance, @Builder works on a record, and so does @Slf4j. So you don't have to abandon one entirely.
A few best practices to keep in mind
- On DTOs, freely use
@Data/@Builder. - On entities, never
@Data/@Value; only@Getter/@Setter+ equals/hashCode withonlyExplicitlyIncludedon the id. - For Spring services,
@RequiredArgsConstructor+finalfields. - For a simple value object, consider
recordfirst, then@Value. - Turn on
lombok.addLombokGeneratedAnnotation = true. - Don't forget
@Builder.Defaultfor every field with a default value. - Use
@SneakyThrowsonly where it genuinely makes sense. - When upgrading the JDK, update Lombok first.
Interview Questions
No, and that distinction is the key answer. Java's official Annotation Processing API only allows generating new files, not modifying an existing class. Lombok registers as an annotation processor so it gets invoked during compilation, but then casts the compiler's objects to internal com.sun.tools.javac types (like JCTree) and mutates the compiler's actual AST directly — planting methods inside the class. The compiler then produces bytecode as if you'd written them. For Eclipse it uses a Java agent. The important consequence: the output is a clean .class with no runtime dependency, but because it relies on internal APIs, it can break with each new Java release.
Because Lombok only works at compile time and plays no role at runtime; the generated .class files contain no reference to Lombok. So you must not ship Lombok inside the final jar and runtime classpath. provided (Maven) and compileOnly (Gradle) say exactly this. In Gradle you must also declare it as an annotationProcessor so it's actually activated during compilation.
@Data = @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor. The entity danger comes from three places: (1) equals/hashCode are built from all fields, but in JPA the id is often null until save and then gets a value — so hashCode changes during the object's lifetime and the entity gets lost in a HashSet/HashMap. (2) toString/hashCode over lazy relationships can trigger an unwanted query or LazyInitializationException, and in a bidirectional relationship toString recurses into a StackOverflowError. (3) Hibernate proxies break the getClass() check. The fix: @EqualsAndHashCode(onlyExplicitlyIncluded = true) with @EqualsAndHashCode.Include on the id only, or don't put @Data on entities at all.
If you put a default value on a field (private int x = 8;) and use @Builder, then when you don't set that field in the builder you get Java's default (zero), not 8. The reason is that the builder has its own independent fields and doesn't see the class-field initializer. To fix it you put @Builder.Default on the field; Lombok then keeps a "was it set?" flag and applies your default when it wasn't set. This is one of the most common silent Lombok bugs.
At the bytecode level there is no checked/unchecked distinction at all; that split is purely a compile-time rule of Java. @SneakyThrows doesn't wrap or swallow anything; it merely fools the compiler into thinking the error is unchecked, and since the JVM permits throwing any exception, it works fine at runtime. Its downside: calling code can't catch that error because the compiler believes such an error is impossible. So use it only where the error is genuinely "can't happen" or where you can't change the method signature (like Runnable.run).
Both create an immutable data bundle (private final fields, constructor, equals/hashCode/toString). Differences: a record is language-native (no dependency, no plugin, stable across JDK upgrades), its accessors are amount() not getAmount(), its immutability is mandatory, and it can't extend another class. @Value depends on Lombok but gives more flexibility (JavaBean-style getter names, compatibility with older tools). Rule: for a simple value object today, record is the first choice; reach for @Value when you need JavaBean-style getters or compatibility with a framework that doesn't understand records.
Because to manipulate the AST, Lombok relies on the compiler's internal APIs (com.sun.tools.javac.*), which Oracle never promises to keep stable. Examples: JDK 16's module system closed access to those packages (does not export), and JDK 21/23 caused NoSuchFieldError by changing internal fields. That's why the rule is: on a JDK upgrade, first bump Lombok to its latest release (e.g. 1.18.46, which supports JDK 24/25).
delombok takes source code containing Lombok annotations and produces a version where all of them are expanded into equivalent plain Java — e.g. @Getter becomes the actual getter method. Two uses: (1) seeing exactly what Lombok generated, for learning and debugging; (2) an exit strategy — if you ever want to remove Lombok, delombok produces the equivalent Lombok-free code and you commit that. It's the practical answer to the worry "what if we get locked into Lombok?"
Because it generates a constructor only for final (and @NonNull) fields, and Spring uses constructor injection. Benefits: fields stay final and therefore immutable and thread-safe; required dependencies are explicit and the object is always constructed fully-formed; and unit testing without booting all of Spring is possible because you just call the constructor with mocks. It's the modern replacement for field-level @Autowired, which made testing harder and left fields mutable.
callSuper = true makes the generated equals/hashCode also factor in the parent class's equals/hashCode; in hierarchies with fields in the parent, forgetting it is a classic bug. onlyExplicitlyIncluded = true flips the behavior from "include all fields except those I excluded" to "include no field except those I marked with @EqualsAndHashCode.Include" — exactly what we want for JPA entities so only the id is considered.
Because those methods read all fields, and if a field is a @OneToMany/@ManyToMany relationship with FetchType.LAZY, reading it forces the proxy to load from the database — an unwanted heavy query. If this happens outside an open transaction or session, you get LazyInitializationException. The fix: keep those fields out of equals/hashCode/toString with @ToString.Exclude and onlyExplicitlyIncluded.
(1) lombok.addLombokGeneratedAnnotation = true puts @lombok.Generated on every generated member so coverage tools like JaCoCo ignore that auto-generated code; without it, coverage percentages drop artificially. (2) config.stopBubbling = true declares the config-tree root here so Lombok won't search above your project for a config file. Another useful one is lombok.<feature>.flagUsage = error, which bans a dangerous usage like @Data across the whole codebase.
Only part of it. Records cover the "immutable value object" territory and are the first choice there because they're native and stable. But Lombok has things records don't: mutable fields and setters (for DTOs and entities), @Builder on an ordinary class, @Slf4j, @RequiredArgsConstructor, and inheritance. You can even put @Builder and @Slf4j on a record itself. So they're not full rivals; they overlap only in the value-object region.
Because var arrived as a language-native feature in Java 10, and Lombok's val can be replaced by native final var — the language absorbed that Lombok capability. But Java still has no built-in builder pattern, and hand-writing a builder is exactly the same heavy boilerplate. As long as the language has nothing equivalent to @Builder, that annotation keeps its value. This is a general pattern: as the language advances, it reclaims parts of Lombok's territory, but not all of it.
Lombok erases boilerplate with a handful of annotations (@Getter/@Setter, @Data, @Value, @Builder, @Slf4j, @RequiredArgsConstructor), and in doing so dries up a whole class of bugs from hand-writing getters/equals/hashCode. Its magic is that it's not a normal annotation processor: it reaches into javac's internal APIs and mutates the AST — the source of both its power and its fragility, which is why it must stay updated across JDK upgrades. Its sweet spot is DTOs, builders, loggers, and dependency injection. Its main trap is @Data on a JPA entity (all-fields equals/hashCode, lazy-loading, StackOverflow), cured by @EqualsAndHashCode(onlyExplicitlyIncluded=true) on the id; its silent trap is @Builder.Default. For simple value objects, native records are today's first choice, but Lombok still earns its place in the territory of mutability, builders, and loggers. Use it with awareness, tune lombok.config, and always keep delombok as an exit route. Less code — but more importantly: fewer bugs.