Lombok Beyond @Data: Six Annotations That Actually Prevent Crashes
Column: Annotation Quick Reference | Environment: JDK 21 | Lombok 1.18.34 | Spring Boot 3.2.7
Starting from a StackOverflow
Suppose colleague A wrote a simple user-order management module. User has a List<Order>, and Order references its owning User — a standard JPA bidirectional association. He thought Lombok's @Data was convenient and added it to both classes.
The functionality ran fine, until he logged a User object for debugging —
The service crashed directly. StackOverflowError.
@Data
@Entity
public class User {
@OneToMany(mappedBy = "user")
private List<Order> orders; // User has a list of Orders
}
@Data
@Entity
public class Order {
@ManyToOne
private User user; // Order references back to User
}
// Calling user.toString() in the log →
// User.toString() calls orders.toString() →
// Each Order.toString() calls user.toString() →
// Infinite recursion → StackOverflowError 💥
@Data is convenient, but applying it indiscriminately can cause trouble. Lombok is far more than @Data; it has 6 truly useful annotations, each saving over a dozen lines of boilerplate — and they won't crash your service.
Prerequisites: Lombok Dependency Configuration
Spring Boot projects manage the Lombok version by default via spring-boot-starter-parent. You only need to introduce the dependency:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>annotationProcessor</scope> <!-- Generates code at compile time, not packaged into jar -->
</dependency>
New versions of IntelliJ IDEA have the Lombok plugin built-in; no extra installation is needed. If the IDE still shows errors after writing Lombok annotations, check if Settings → Build → Compiler → Annotation Processors → Enable annotation processing is enabled.
First Trick: @Builder / @SuperBuilder — Builder Pattern in One Line
Before It Existed
A class with 4 fields requires 60+ lines of code to hand-write the Builder pattern:
public class User {
private String name;
private int age;
private String email;
private String phone;
// Private constructor
private User(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.email = builder.email;
this.phone = builder.phone;
}
// Builder inner class — 40 lines of boilerplate
public static class Builder {
private String name;
private int age;
private String email;
private String phone;
public Builder name(String name) { this.name = name; return this; }
public Builder age(int age) { this.age = age; return this; }
public Builder email(String email) { this.email = email; return this; }
public Builder phone(String phone) { this.phone = phone; return this; }
public User build() {
if (name == null) throw new IllegalStateException("name cannot be null");
return new User(this);
}
}
public static Builder builder() { return new Builder(); }
}
As fields increase, the Builder inner class becomes a "repetitive labor machine" — field declarations are written twice (once in the class, once in the Builder), and assignment code is mechanical repetition.
After Using It
@Builder one annotation handles everything:
@Builder
@ToString
public class User {
private String name;
private int age;
private String email;
private String phone;
}
// Usage: Chained calls, field names as method names
User user = User.builder()
.name("Zhang San")
.age(25)
.email("[email protected]")
.phone("13800138000")
.build();
Reduced from 60+ lines to ~15 lines. For classes with ≥4 fields, the Builder pattern elevates code readability by a level — passing parameters like new User("Zhang San", 25, "[email protected]", "138...") means you won't understand what each parameter is two weeks later.
Key Pitfall — Inheritance Scenarios
@Builder has a very common hidden pitfall: Parent class fields do not appear in the child class's Builder.
@Builder
class Animal {
private String name;
private int age;
}
@Builder
class Dog extends Animal {
private String breed;
}
// Dog.builder().name("Wangcai").age(3).breed("Golden Retriever").build(); ← Compilation error!
// Dog.builder() only has breed() method, no name() and age()
Solution: @SuperBuilder. Annotate both parent and child classes with @SuperBuilder:
@SuperBuilder
@ToString
class Animal {
private String name;
private int age;
}
@SuperBuilder
@ToString(callSuper = true)
class Dog extends Animal {
private String breed;
}
// Now it works —
Dog dog = Dog.builder()
.name("Wangcai") // Parent field ✅
.age(3) // Parent field ✅
.breed("Golden Retriever") // Child field ✅
.build();
What You Need to Know
@Builderdoes not support inheritance — use@SuperBuilderwhen inheritance is needed, and both parent and child classes must be annotated- The constructor generated by
@Builderis package-private by default — this means you cannot directlynew User()in other packages. If you want cross-package instantiation, additionally add@AllArgsConstructor(default public). If you want to force everyone to create via Builder (including within the same package), add@AllArgsConstructor(access = AccessLevel.PRIVATE)to make the constructor truly private @Builderignores field default values — for example,private boolean enabled = true;, after Builder constructionenabledwill befalse(Java's zero value). Solution: add@Builder.Defaulton the field- Builder pattern does not mean "use for all classes" — for simple DTOs with only 2-3 fields, using a constructor directly is clearer
Second Trick: @SneakyThrows — No More Ugly try-catch in Stream/Lambda
Scenario
Calling a method declared throws IOException inside a Stream's map(). Compilation error directly — because the Function interface accepted by Stream.flatMap() does not declare throws, the lambda body cannot throw checked exceptions.
// Files.lines() declares throws IOException
// This code directly fails to compile ❌
List<String> lines = fileNames.stream()
.flatMap(name -> Files.lines(Path.of(name))) // Compilation error!
.toList();
Before It Existed
Wrap a try-catch inside the lambda, converting to RuntimeException in the catch:
List<String> lines = fileNames.stream()
.flatMap(name -> {
try {
return Files.lines(Path.of(name));
} catch (IOException e) {
throw new RuntimeException(e); // Ugly!
}
})
.toList();
Lambdas should be concise, but the try-catch bloats them longer than a method body.
After Using It
Extract the logic that throws checked exceptions into a method, add @SneakyThrows:
@SneakyThrows
private static Stream<String> readLines(String path) {
return Files.lines(Path.of(path)); // Compiler no longer complains
}
// Lambda is clean now
List<String> lines = fileNames.stream()
.flatMap(Demo::readLines)
.toList();
What You Need to Know
@SneakyThrowsdoes not silently swallow exceptions at runtime — exceptions are still thrown normally, only thethrowsdeclaration is hidden at the bytecode level during compilation. In the example above, if the file does not exist,NoSuchFileExceptionwill be thrown normally- Do not abuse — checked exceptions exist for a reason (e.g.,
IOExceptionreminds the caller to handle I/O failure). Only use when you are certain the caller can handle this exception and you don't want to pollute the upper method signature - Difference from
try-catch+throw new RuntimeException():@SneakyThrowsthrows the original exception (type unchanged), while try-catch converts it toRuntimeException
Third Trick: @Cleanup — Resource Management More Concise Than try-with-resources
Before It Existed
Java 7 introduced try-with-resources, already much cleaner than traditional try-finally:
try (InputStream is = new FileInputStream("data.txt");
InputStreamReader isr = new InputStreamReader(is);
BufferedReader reader = new BufferedReader(isr)) {
// Read data...
} // Automatically closed in reverse declaration order: reader → isr → is
But this still requires nested bracket declarations; with many resources, the brackets are longer than the content.
After Using It
@Cleanup InputStream is = new FileInputStream("data.txt");
@Cleanup InputStreamReader isr = new InputStreamReader(is, StandardCharsets.UTF_8);
@Cleanup BufferedReader reader = new BufferedReader(isr);
// Read data normally...
// At the end of the current scope, reader → isr → is are automatically close()'d in reverse declaration order
No nested brackets, resource declarations and business code at the same indentation level, reading flatter.
Key Pitfall — Scope Trap
@Cleanup resources are closed only when the variable's scope ends. If you declare @Cleanup at the beginning of a method and call a time-consuming operation in between (like an RPC call), the resource will be held until the method ends.
// ❌ Bad: Resource declared too early, held for too long
public void badExample() {
@Cleanup InputStream is = new FileInputStream("bigfile.bin");
// ... 3-second RPC call in between ...
processData(is); // Resource held until method end
}
// ✅ Good: Use {} to narrow scope
public void goodExample() {
{
@Cleanup InputStream is = new FileInputStream("bigfile.bin");
processData(is);
} // ← Closed here, no need to wait until method end
// Subsequent time-consuming operations won't hold the file resource
}
What You Need to Know
@Cleanup's closing order is the same as try-with-resources: Closes in reverse declaration order (last declared, first closed)- Must implement
AutoCloseableorCloseableinterface — otherwise@Cleanupcauses a compilation error - Compared to try-with-resources, which is better? try-with-resources is Java standard syntax, more familiar to most teams;
@Cleanupis more concise but depends on Lombok. They are functionally equivalent, choose whichever your team prefers
Fourth Trick: @UtilityClass — The Correct "Official" Way to Write Utility Classes
Before It Existed
Writing a utility class requires following a bunch of "rules":
public final class StringUtils { // ① Class must be final, prevent inheritance
private StringUtils() { // ② Private constructor, prevent external new
throw new UnsupportedOperationException("Utility class cannot be instantiated"); // ③ Prevent reflection
}
public static String toUpperCase(String str) { // ④ All methods must be static
return str == null ? null : str.toUpperCase();
}
public static boolean isEmpty(String str) {
return str == null || str.isEmpty();
}
}
Four rules; missing one creates a hidden danger — forget final, someone inherits your utility class; forget private constructor, someone does new StringUtils().
After Using It
@UtilityClass one annotation, automatically completes all four:
@UtilityClass
public class StringUtils {
public String toUpperCase(String str) { // No need to write static — automatically added
return str == null ? null : str.toUpperCase();
}
public boolean isEmpty(String str) {
return str == null || str.isEmpty();
}
}
// Usage is exactly the same
String result = StringUtils.toUpperCase("hello"); // → "HELLO"
Code automatically generated by @UtilityClass:
- Class becomes
final - All methods become
static - Generates a private constructor (throws exception internally)
- Prevents external instantiation — dual protection of private constructor + internal exception throw
What You Need to Know
- No need to write
statickeyword in methods —@UtilityClassautomatically makes all methods static. You writepublic String toUpperCase(...)in source code, it becomespublic static String toUpperCase(...)after compilation - Suitable for all "utility classes with only static methods":
StringUtils,DateUtils,CollectionUtils,FileUtils, etc. - Not suitable for classes that need instantiation or inheritance — utility classes are inherently "stateless + pure functions". If a class needs
@Autowireddependency injection, it shouldn't be a utility class
Fifth Trick: @With — The Correct Way to "Modify" a Field on Immutable Objects
Scenario
You used @Value to create an immutable object (all fields private final, only getters). This is great practice — immutable objects are thread-safe, no need to worry about concurrent modification. But what to do when you need to update a field?
Before It Existed
Hand-write withXxx() methods — create a new object, copy all fields, replace the target field:
@Value
public class User {
String name;
int age;
String email;
// Hand-write with methods
public User withName(String newName) {
return new User(newName, this.age, this.email);
}
public User withAge(int newAge) {
return new User(this.name, newAge, this.email);
}
public User withEmail(String newEmail) {
return new User(this.name, this.age, newEmail);
}
}
As fields increase, each field needs a hand-written with method — repetitive labor again.
After Using It
@With automatically generates withXxx() for all fields:
@Value
@With
public class User {
String name;
int age;
String email;
}
// Usage: Chained "modification", each returns a new object
User user = new User("Zhang San", 25, "[email protected]");
User updated = user.withName("Zhang Sanfeng").withAge(30).withEmail("[email protected]");
// Original object unaffected
System.out.println(user); // User(name=Zhang San, age=25, [email protected])
System.out.println(updated); // User(name=Zhang Sanfeng, age=30, [email protected])
What You Need to Know
withXxx()returns a new object (shallow copy) — only copies references, not deep copy. If a field isList<String>, old and new objects will share the same List reference (manually handle deep copy inside thewithmethod if needed)- Best used with
@Value—@Valueensures object immutability,@Withprovides the only legitimate channel for "modification" - Don't want to generate
withmethods for certain fields? Add@With(AccessLevel.NONE)on the field to exclude
Sixth Trick: @Singular — Adding Collection Field Elements One by One
Scenario
A class with @Builder has a List<String> tags field. When using Builder, you must first construct a complete List to pass in:
// Before it existed: Must first new an ArrayList (or use List.of)
Article article = Article.builder()
.title("Lombok Tutorial")
.tags(List.of("Java", "Lombok", "Annotations")) // Must construct complete List at once
.build();
After Using It
Add @Singular on the collection field, and the Builder will have additional element-by-element addition methods:
@Builder
@ToString
public class Article {
private String title;
@Singular
private List<String> tags; // Tags
@Singular("contributor")
private Set<String> contributors; // Contributors (singular method name specified as "contributor")
@Singular
private Map<String, Integer> scores; // Scores
}
// Usage: Add elements one by one, code is more natural
Article article = Article.builder()
.title("Lombok Annotation Quick Reference")
.tag("Java") // Singular form, add one by one
.tag("Lombok")
.tag("Annotation Quick Reference")
.contributor("Zhang San") // Specified singular method name "contributor"
.contributor("Li Si")
.score("Zhang San", 95) // Map singular form: score(key, value)
.score("Li Si", 88)
.build();
Additional methods generated by @Singular (using tags as example):
tag(T element)— Add single element one by one (singular form, method name auto-inferred by Lombok)tags(Collection<? extends T>)— Add all elements in batch (plural form, overwrites previously added)clearTags()— Clear all previously added elements
What You Need to Know
- Singular method name is auto-inferred by Lombok —
tags→tag,items→item,users→user. If inference is wrong (e.g.,children→ Lombok might not inferchild), use@Singular("customName")to explicitly specify - Supported collection types:
List,Set,SortedSet,NavigableSet,Map,SortedMap,NavigableMap(corresponding Guava immutable collections also supported) - clear method is very useful — e.g., use Builder's default values first, then
clearTags()under specific conditions and re-add
Extra: The Correct Way to Use @Data
Back to the StackOverflow at the beginning of the article.
@Data is actually a "composite annotation", equivalent to:
@Data = @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor
Five annotations bundled together, convenient indeed, but JPA Entity has its absolute forbidden zones:
| Problem | Cause | Consequence |
|---|---|---|
@ToString infinite recursion |
Bidirectional association's toString() calls each other |
StackOverflowError |
@EqualsAndHashCode unstable |
Uses all fields (including @Id) to calculate hash. @GeneratedValue ID is null before persist(), hash based on object identity; after persist() ID has value, hash changes |
Same object appears in two positions in Set/HashMap |
@Setter breaks encapsulation |
Bidirectional association's setter called incorrectly, only sets one end, forgets the other | Data inconsistency |
Correct Approach
@Getter
@Setter
@ToString(exclude = "orders") // Break recursion chain
public class User {
@OneToMany(mappedBy = "user")
private List<Order> orders;
}
@Getter
@Setter
@ToString(exclude = "user") // Break recursion chain
public class Order {
@ManyToOne
private User user;
}
Simple rule:
- JPA Entity → Use
@Getter+@Setter(or just@Getter), not@Data - DTO / POJO / VO → Feel free to use
@Data, pure data objects without JPA associations are completely fine
Quick Reference Table
| Annotation | One Sentence | Best Scenario | Key Pitfall |
|---|---|---|---|
@Builder |
Generates Builder pattern | Classes with ≥4 fields | Does not support inheritance (use @SuperBuilder instead) |
@SuperBuilder |
Builder supporting inheritance | Parent class has common fields, child class extends | Both parent and child classes must be annotated |
@SneakyThrows |
Hides throws at bytecode level |
Calling checked exception methods in Stream/Lambda | Don't abuse — checked exceptions exist for a reason |
@Cleanup |
Auto close() at scope end |
I/O streams, JDBC connections | Closes only at scope end, use {} to narrow scope |
@UtilityClass |
Standardized utility class | Utility classes with pure static methods | Don't write static on methods, automatically added |
@With |
Field cloning for immutable objects | @Value immutable objects |
Shallow copy, nested objects need manual deep copy |
@Singular |
Build collection fields element by element | @Builder with List/Set/Map |
Singular method name may be inferred incorrectly |
Summary
- Discern scenarios for JPA Entity with
@Data— Use@Getter+@Setterseparately,@ToString.Excludeto break bidirectional recursion @Builder+@SuperBuilderare the biggest productivity boosters in daily coding — Use Builder directly for classes with ≥4 fields,@SuperBuilderfor inheritance scenarios@SneakyThrows,@Cleanup,@UtilityClass,@With,@Singularare small but practical — One annotation replaces over a dozen lines of boilerplate code, knowing they exist is productivity. Next time you encounter writing try-catch in Stream or hand-writing Builder inner classes, think of these six
Lombok is not just @Data. Used correctly, your Java code can have 30% less boilerplate — and won't crash the service because of logging.
Complete Source Code
The complete code for this article's demos has been uploaded to Gitee:
https://gitee.com/gcchech/articles-demo
Enter the lombok-annotations/ directory:
mvn spring-boot:run
All annotation demo code is organized by module under the com.coderplus.lombok package, each Demo runs independently, output content see below. Note: For teaching convenience, class names in the article have been simplified (e.g., WithBuilderDemo → User, After → StringUtils), functionality is completely identical.
Actual Runtime Verification
Below is the real console output from a local JDK 21 + Spring Boot 3.2.7 + Lombok 1.18.34 environment (Spring startup logs removed).
@Builder / @SuperBuilder
[Handwritten Builder] User{name='Zhang San', age=25, email='[email protected]', phone='13800138000'}
[Handwritten Builder] Lines of code: 60+ lines (constructor + Builder inner class + repeated field declarations)
[@Builder] WithBuilderDemo(name=Zhang San, age=25, [email protected], phone=13800138000)
[@Builder] Lines of code: ~15 lines (only field declarations + @Builder + @ToString)
[@SuperBuilder] InheritanceDemo.Dog(super=InheritanceDemo.Animal(name=Wangcai, age=3), breed=Golden Retriever)
[@SuperBuilder] Both parent Animal and child Dog annotated with @SuperBuilder, parent fields automatically appear in child Builder
@SneakyThrows
[@SneakyThrows] Successfully read 2 lines of content
[@SneakyThrows] No more need for try-catch wrapping in lambda, methods marked with @SneakyThrows hide throws declaration at bytecode level during compilation
[@SneakyThrows] Exceptions are still thrown normally (not silently swallowed): NoSuchFileException
@Cleanup
[@Cleanup] Read file content:
Hello @Cleanup!
Second line of content
[@Cleanup] After current scope ends, reader → isr → fis automatically closed in reverse declaration order
[@Cleanup] Compared to try-with-resources: saves nested () declarations, code is flatter
[@Cleanup ⚠️] Note scope trap: resources are closed only when the variable's scope ends,
if @Cleanup is declared at method start with time-consuming operations in between, resources will be held.
Solution: Wrap with {} to narrow scope — just like demonstrated above.
@UtilityClass
[Handwritten version] Before.toUpperCase('hello'): HELLO
[Handwritten version] Before.isEmpty(''): true
[Handwritten version] Before.defaultIfEmpty(null, 'default'): default
[@UtilityClass] After.toUpperCase('hello'): HELLO
[@UtilityClass] After.isEmpty(''): true
[@UtilityClass] After.defaultIfEmpty(null, 'default'): default
[@UtilityClass] Handwritten version 20+ lines of boilerplate → @UtilityClass version 14 lines, and less error-prone
[@UtilityClass] Reflection also cannot instantiate: java.lang.IllegalAccessException: ... cannot access ... with modifiers "private"
@With
[@With] Original object: WithDemo.User(name=Zhang San, age=25, [email protected])
[@With] After modifying age: WithDemo.User(name=Zhang San, age=30, [email protected])
[@With] Original object not modified: WithDemo.User(name=Zhang San, age=25, [email protected])
[@With] Are the two objects the same reference: false
[@With] After chained modification: WithDemo.User(name=Zhang Sanfeng, age=25, [email protected])
[@With] Key: Each withXxx() returns a new object (shallow copy), original object unchanged. Suitable for implementing immutable data models with @Value
@Singular
[@Singular] SingularDemo.Article(title=Lombok Annotation Quick Reference, tags=[Java, Lombok, Annotation Quick Reference], contributors=[Zhang San, Li Si], scores={Zhang San=95, Li Si=88})
[Without @Singular] SingularDemo.Article(title=Lombok Annotation Quick Reference, tags=[Java, Lombok, Annotation Quick Reference], contributors=[Li Si, Zhang San], scores={Li Si=88, Zhang San=95})
[@Singular] Advantage: Can add elements one by one as needed, code is more natural and readable.
[@Singular] Generated additional methods: tag(T) / clearTags() / tags(Collection) etc.
[@Singular clearTags] SingularDemo.Article(title=Test, tags=[Final Tag], contributors=[], scores={})
@Data Pitfalls vs Correct Approach
[@Data Wrong] Attempting to print user object...
[@Data Wrong] ❌ StackOverflowError!
[@Data Wrong] Reason: User.toString() → orders.toString() →
Each Order.toString() → user.toString() → Infinite recursion
[@Getter + @ToString.Exclude Correct] DataPitfallDemo.GoodUser(name=Zhang San)
[@Getter + @ToString.Exclude Correct] DataPitfallDemo.GoodOrder(product=Computer)
[Correct] @ToString(exclude="orders") and @ToString(exclude="user") break the circular reference
[Best Practice] Never use @Data on JPA Entity! Only use @Getter + @Setter, explicitly write or use @ToString.Exclude when toString is needed
Verification Checklist
| Verification Item | Annotation | Result |
|---|---|---|
| Builder replaces 60+ lines of boilerplate | @Builder |
✅ |
| Parent fields available in inheritance scenario | @SuperBuilder |
✅ |
| Calling throws method in Stream no error | @SneakyThrows |
✅ |
| Exception still thrown normally, not swallowed | @SneakyThrows |
✅ |
| Resources auto-closed in reverse order | @Cleanup |
✅ |
| Utility class prevents instantiation (including reflection) | @UtilityClass |
✅ |
| with method returns new object, original unchanged | @With |
✅ |
| Build collection element by element | @Singular |
✅ |
| clearTags clears added elements | @Singular |
✅ |
| @Data bidirectional association StackOverflow | — | ✅ Reproduced |
| @ToString.Exclude breaks recursion | — | ✅ |